Compare commits

80 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 522e59f8f5 Bump version to 0.4.0 (from 0.3.0) for release; refresh contributors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:02:26 -04:00
Brandon Thomas caa4872df2 Merge pull request #76 from grovebit/clip-gain
Change clip gain with Ctrl+Shift+Up/Down and a fader on the clip
2026-10-09 20:52:40 -04:00
Brandon ThomasandClaude Opus 5.5 ff2bd2d3f8 Clip gain fader popup: use the current theme tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:50:08 -04:00
Brandon Thomas 53e1d3c624 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-09 20:49:56 -04:00
Brandon Thomas ac2435e18e Merge pull request #54 from vbasky/feature/session-audio-health
Report session audio health without changing the session
2026-10-09 20:49:44 -04:00
Brandon ThomasandClaude Opus 5.5 50f885e16b Merge origin/main into feature/session-audio-health
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:48:05 -04:00
Brandon Thomas 7c8d339a8f Merge pull request #66 from MarekSpodBiedry/issue-62
Show Ctrl instead of Cmd in shortcut text off macOS (#62)
2026-10-09 20:46:12 -04:00
Brandon ThomasandClaude Opus 5.5 4216d4f1be Merge origin/main into issue-62
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:41:26 -04:00
Brandon Thomas 504b293814 Merge pull request #65 from salignatmoandal/feat/inspect-source-loaded
inspect: report `sources[].loaded` for agents
2026-10-09 20:41:00 -04:00
Brandon ThomasandClaude Opus 5.5 e2bfdd5b73 Merge origin/main into feat/inspect-source-loaded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:37:45 -04:00
Brandon Thomas d7fc5dc383 Merge pull request #61 from salignatmoandal/fix/import-audio-new-tracks
Honor `file.import_audio new_tracks` (#60)
2026-10-09 20:37:13 -04:00
Brandon ThomasandClaude Opus 5.5 66a682fba1 Merge origin/main into fix/import-audio-new-tracks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:35:14 -04:00
Brandon Thomas e172fef323 Merge pull request #53 from MarekSpodBiedry/issue-47
convert and bounce: refuse output extensions the encoder cannot write instead of saving WAV data (#47)
2026-10-09 20:34:53 -04:00
Brandon ThomasandClaude Opus 5.5 45e32b9781 Merge origin/main into issue-47
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:32:52 -04:00
Brandon Thomas eb340fcc4a Merge pull request #58 from MarekSpodBiedry/issue-57
Warn about missing media when opening a session (#57)
2026-10-09 20:32:32 -04:00
Brandon ThomasandClaude Opus 5.5 3a3c447904 Merge origin/main into issue-57
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:30:26 -04:00
Brandon Thomas 7e966a3d54 Merge pull request #52 from MarekSpodBiedry/issue-50
CLI: run --cmd and app reject malformed JSON instead of using empty params (#50)
2026-10-09 20:30:04 -04:00
Brandon ThomasandClaude Opus 5.5 7f258b28a7 Merge origin/main into issue-50
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:26:37 -04:00
Brandon Thomas 6ff36097d9 Merge pull request #51 from MarekSpodBiedry/issue-48
soundcraft-cli --help: stop at the closing code fence, no Rust source lines (#48)
2026-10-09 20:25:43 -04:00
21f9b07074 Add a selectable Light theme (#32)
* Add a selectable Light theme

* Apply the active theme to the menu bar

* Use the system theme by default

* Expose Appearance in the Setup menu

* Add Light theme screenshot

* Remove the Light theme screenshot

Repo policy keeps large binary assets out of the tree, and the PNG had no
ATTRIBUTION row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Appearance: ui.theme command, Dark default, non-blocking system lookup

- ThemeMode derives Default with Dark as the default (fixes clippy's
  derivable_impls); System and Light are opt-in. LIGHT_THEME starts false.
- New UI command `ui.theme {"mode": "system|light|dark"}` (label
  "Appearance") in UI_COMMANDS; with no mode it reports the current one.
  The UI Customization combo box dispatches it instead of writing state.
- The choice lives in UiState.theme, so it persists with the existing UI
  prefs (ui.json) and is exposed through ui.set / ui.inspect. Restoring a
  window configuration keeps the current theme.
- dark_light::detect() is only called in System mode, on a background
  thread (an AtomicU8 holds the answer; dark until it arrives, then a
  repaint). On the web it is the synchronous matchMedia lookup.
- theme::apply runs only when the effective palette changes, not every
  frame.
- window.ui_customization keeps its catalog path (Window > UI
  Customization) so parity is unchanged; Setup > Appearance still opens
  it. The combo row is labelled "Theme" so it no longer repeats the title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Light theme: move hard-coded colours to theme tokens

The Light palette left dark islands and unreadable text where colours
were Color32 literals. New tokens (Dark values are the old literals, so
the Dark theme renders pixel-identical to main):

- menu bar; toolbar group outline; edit-mode cells (no more dark block
  around Shuffle/Spot/Slip/Grid); counter panel outlines, dividers and
  off toggles; Smart-tool bracket
- selected rows in the Tracks/Groups/Clips lists, track-visible dot,
  selected Edit track header, selected Mix strip
- marker names, alternate-playlist lanes, Universe background/outline,
  Big Counter background
- meter background and clip lamp, pan knob body/ring/pointer, Mix insert
  and send slots, Mix section captions

In Light, clip bodies are a paler tint and waveforms a deep shade of the
clip colour instead of black.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Light theme: instrument slot and plugin window text use the current tokens

The instrument picker (#23) landed with Tokens::DARK literals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 18:37:04 -04:00
Brandon Thomas 5f2ce943a6 Merge pull request #10 from reoniazx/fix/macos-native-menu-bar
macOS: move application menus to the system menu bar
2026-10-09 18:27:25 -04:00
Brandon Thomas 03972e20ca Merge pull request #34 from DWAA1660/fix/windows-dx12-startup
Default Windows startup to DirectX 12
2026-10-09 18:27:19 -04:00
Brandon Thomas c9f94e0249 Merge pull request #23 from ebbicott/feature/plugin-menus-instruments
Plugin menus by vendor (scrollable), Audio Units inserts, and an instrument picker
2026-10-09 18:25:06 -04:00
Brandon ThomasandClaude Opus 5.5 a867f05177 Merge origin/main into feature/plugin-menus-instruments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:23:33 -04:00
Brandon Thomas 5a0ea9d35d Merge pull request #17 from ebbicott/fix/vst3-module-exit
Fix the crash on quit after a VST3 scan: run module exits on the entry thread
2026-10-09 18:23:18 -04:00
Brandon ThomasandClaude Opus 5.5 acf3237bed Merge origin/main into fix/macos-native-menu-bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:15:50 -04:00
Brandon ThomasandClaude Opus 5.5 6e55d9e04f Merge origin/main into fix/windows-dx12-startup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:15:02 -04:00
Brandon ThomasandClaude Opus 5.5 2e2220c64d Merge origin/main into fix/vst3-module-exit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:13:17 -04:00
Brandon Thomas 92b4d07a7c Merge pull request #11 from switch87/desktop-log-file
Desktop: write log records to standard error and a rotating log file
2026-10-09 18:12:57 -04:00
Brandon ThomasandClaude Opus 5.5 5824042856 Merge origin/main into desktop-log-file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:12:03 -04:00
Brandon Thomas 2c495aa473 Merge pull request #31 from vbasky/feat/fontconfig-system-fonts
Use fontconfig for Linux system fonts
2026-10-09 18:11:45 -04:00
Brandon Thomas 7083a58500 Merge pull request #29 from Garygunn94/claude/track-delete-menu-disappear-9456d6
Keep the clip context menu open while moving onto it
2026-10-09 18:11:39 -04:00
Brandon Thomas e9820fac30 Merge pull request #28 from Garygunn94/claude/recording-audio-playback-b2db9a
Fix input monitoring pitch and dropouts when input and session rates differ
2026-10-09 18:11:35 -04:00
Brandon Thomas 4bb041ad5c Merge pull request #26 from Garygunn94/feat/metronome-click
Make the Click option audible: metronome in live playback
2026-10-09 18:11:29 -04:00
Brandon Thomas d4a20130f0 Merge pull request #25 from Garygunn94/claude/tempo-value-not-saving-12cc98
Fix tempo change from the tempo ruler not saving
2026-10-09 18:11:16 -04:00
Brandon Thomas 30dd2c84d1 Merge pull request #36 from reoniazx/fix/timeline-follow-hold
Pause playhead follow when the timeline is moved away during playback
2026-10-09 18:11:11 -04:00
Brandon Thomas 286ac34e26 Merge pull request #20 from MarekSpodBiedry/issue-15
Fix keyboard shortcuts on Windows and Linux: Ctrl is the Cmd key (#15)
2026-10-09 18:11:06 -04:00
Brandon Thomas 6fbb68f5e1 Merge pull request #39 from hamada147/fix/no-audio
fix(Play Audio): Resume the web AudioContext on the first user gesture
2026-10-09 18:11:01 -04:00
Brandon Thomas 886278fa21 Merge pull request #37 from reoniazx/fix/automation-first-pass-overflow
Fix debug overflow panic on the first automation-write pass
2026-10-09 18:10:56 -04:00
Brandon Thomas 8958059cd9 Merge pull request #44 from storytold/ci/freebsd-editor-test
Fix FreeBSD CI: VST3 editor tests expect 'unsupported' off macOS
2026-10-09 18:10:32 -04:00
grovebit 1720fc7ea1 Change clip gain with Ctrl+Shift+Up/Down and a fader on the clip
There was no way to change a clip's gain from the UI (#2): clip gain is
stored, drawn and settable through `clip.gain`, but no menu item, key or
gesture reached it. This adds the two ways Pro Tools offers.

Keyboard: Pro Tools nudges the selected clips' gain with
Control+Shift+Up/Down, 1 dB a step by default. Two new commands, Nudge
Clip Gain Up and Down, do the same through `clip.gain`'s relative
`delta_db`, so its -144..+36 dB range holds and each nudge is one undo
step. Control+Up/Down extended the edit selection whether or not Shift
was held, which swallowed the new keys; it now leaves the Shift variants
to the command registry.

Fader: in Pro Tools, Clip Gain Info puts a fader icon and the gain value
in the bottom-left of each clip, and pressing the icon pops up a small
fader. With View > Clip > Clip Gain Info on, or once a clip has gain,
audio clips now show the icon and the value, and dragging the icon moves
the clip's gain from -144 to +36 dB (Cmd for fine moves), one undo step
per drag. The icon is drawn in code. The fader follows the pointer from
where it was pressed rather than adding up per-frame deltas, which lose
the first few pixels while egui decides between a click and a drag and
leave the fader out of step with the pointer once it hits an end (it
would stick, then jump).

Clip Gain Info no longer depends on Clip Gain Line being shown, since
the two are separate views in Pro Tools, and the value moved from the
clip's bottom-right to beside the icon. Dragging the icon of a clip when
nothing is selected selects that clip first, because `clip.gain` needs a
selection.
2026-10-09 22:45:39 +01:00
MarekSpodBiedry 7324e24fae Show Ctrl instead of Cmd in shortcut text off macOS (#62) 2026-10-09 21:27:11 +02:00
Mawen Salignat-Moandal 822b320d1a Report whether each inspect source has decoded audio loaded.
Agents using session.inspect / inspect_session can tell missing media from metadata alone, complementing open_session missing lists and audio_health. Also credit Mawen in people.toml.
2026-10-09 21:24:17 +02:00
Mawen Salignat-Moandal ff4d8910fa Credit Mawen Salignat-Moandal in the About contributors list. 2026-10-09 21:22:24 +02:00
Mawen Salignat-Moandal 8a1c82733c Honor file.import_audio new_tracks instead of silently ignoring it.
Explicit track still wins; new_tracks:false uses the selected audio track or errors clearly. Fixes #60.
2026-10-09 21:03:36 +02:00
MarekSpodBiedry f8a156d255 Warn about missing media when opening a session (#57) 2026-10-09 20:28:46 +02:00
Vikram Bhaskaran 42e6b96734 Report session audio health without changing the session.
The window checks every playlist, including inactive takes, for missing audio, rate mismatches, empty buffers, and clips that extend past their source.
2026-10-10 04:37:45 +11:00
MarekSpodBiedry 43f1fb5681 convert and bounce: refuse output extensions the encoder cannot write instead of saving WAV data (#47) 2026-10-09 19:30:25 +02:00
MarekSpodBiedry 258c63930e CLI: run --cmd and app reject malformed JSON instead of using empty params (#50) 2026-10-09 19:29:46 +02:00
MarekSpodBiedry ff564ea4f0 soundcraft-cli --help: stop at the closing code fence, no Rust source lines (#48) 2026-10-09 19:29:29 +02:00
tandClaude Opus 5.5 dd52eb7dad macOS menu bar: fall back instead of failing to start; throttle sync
- A muda error now logs a warning and keeps the in-window menu bar
  instead of aborting startup (never crash).
- sync() walked ~500 leaves with a command lookup and a Cocoa call each
  frame; run it at most 4x/s and right after a click.
- About / Session Info from the application menu open their window
  rather than toggling it closed.
- The title carries Edit/Mix and the dirty mark only when the system
  menu bar replaces the egui one (no duplicate on Linux/Windows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:12:12 -04:00
tandClaude Opus 5.5 d5f70bf09e dialogs: keep the Dialog enum rustfmt-clean
A doc comment on one variant made rustfmt expand every variant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:05:24 -04:00
tandClaude Opus 5.5 c04e79714c fonts: fontconfig on FreeBSD too; accept only TrueType/OpenType results
egui panics on Type 1 or bitmap font bytes, which fc-match can return on
minimal systems, so check the sfnt magic first. Mask the variable-font
named instance out of %{index}. Fix the unused 'bold' warning on macOS
and Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:00:17 -04:00
tandClaude Opus 5.5 916f453690 mix: no click while only monitoring input with the transport stopped
The stopped position does not advance, so a beat at the stop point
re-rendered its first 30 ms every block: a ~94 Hz buzz whenever a track
was record-armed or monitoring input. Regression test included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:58:38 -04:00
tandClaude Opus 5.5 6c3f2dbac8 After Playback: scroll via view.scroll, and only when the stop point is off-screen
Stop no longer writes the session's scroll directly (everything is a
command) and leaves the view alone when the stopped position is visible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:57:18 -04:00
Brandon Thomas a4c371b18b Revert "Do not yank the view to the playhead on play start"
This reverts commit 0ffd2a1441.
2026-10-09 10:56:45 -04:00
tandClaude Opus 5.5 667e32152e shortcuts: keep Cmd+Ctrl apart from Cmd on macOS; detect the Mac at runtime
On macOS a held Cmd made the Ctrl state ignored, so Cmd+Ctrl+X ran Cut
instead of Cut Clip Gain. Ask egui for the OS so the web build in a Mac
browser uses Mac modifiers too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:55:42 -04:00
Brandon ThomasandClaude Opus 5.5 f0a0ac8318 vst3-host tests: expect editor 'unsupported' off macOS
VST3 editor windows are hosted on macOS only, but editor_main_thread and
fixture_e2e asserted a probed view size on every platform, failing the
FreeBSD job on every commit to main (and Linux locally). Gate only the
size assertion; the controller round trips still run everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:42:37 -04:00
Brandon Thomas eac0edd896 Merge pull request #12 from XusBadia/dmg-background
macOS DMG: branded window background and icon layout
2026-10-09 09:20:50 -04:00
Ahmed Moussa b63a13925b fix(Play Audio): Resume the web AudioContext on the first user gesture
cpal (wasm) creates its output stream at startup, before any user gesture, so the browser leaves the WebAudio AudioContext 'suspended'. As a result no audio plays, and the transport clock never advances because the audio callback drives it.

Wrap the AudioContext constructor before the wasm module loads so that every context cpal creates is captured. On the first pointer, mouse, touch or key event, resume any context that is still suspended.

Signed-off-by: Ahmed Moussa <moussa.ahmed95@gmail.com>
2026-10-09 13:07:37 +03:00
Xus BadiaandClaude Opus 5.5 617888baeb macOS DMG: branded window background and icon layout
The DMG opened as a plain Finder window. It now shows the app icon's figure on
the SoundCraft colour, with SoundCraft.app and the Applications link side by side.
Same approach as PhotoCraft's and VectorCraft's (storytold/photocraft#1201,
storytold/vectorcraft#493).

- packaging/macos/dmg/: background.svg (source; text outlined, no paper grain,
  the figure an <image> of assets/app-icon), background.tiff (1x + 2x HiDPI,
  16-colour palette, Deflate, sRGB; ~28 KB)
  and dmg-layout.DS_Store (Finder view settings: window, icon size 128,
  positions, background).
- generate.py writes the TIFF and the layout on any OS, byte-for-byte
  reproducible, with nothing from the machine that ran it (no paths, users,
  disk names or volume UUIDs).
- package.sh copies them into the staging folder; the image is still built with
  hdiutil makehybrid. Nothing extra runs in the signing job.
- The volume name drops the version: the layout finds the background through
  an alias that includes the volume name. The DMG file name keeps the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:43:45 +02:00
reoniazx 0ffd2a1441 Do not yank the view to the playhead on play start 2026-10-09 13:28:58 +08:00
reoniazx da970b2490 Fix debug overflow panic on the first automation-write pass 2026-10-09 13:24:37 +08:00
reoniazx 5b7864c8f7 Pause playhead follow when the timeline is moved away during playback 2026-10-09 13:22:39 +08:00
DWAA1660andClaude Opus 5.5 c25a873d3f Default Windows startup to DirectX 12
eframe's default wgpu instance enables PRIMARY | GL on Windows. Creating
the OpenGL backend can crash inside AMD's atio6axx.dll before the window
appears, so the app exits silently at launch. When WGPU_BACKEND is not
set, the Windows instance now enables only DirectX 12; an explicit
WGPU_BACKEND is still honoured. macOS, Linux and web are unchanged.

Same fix as storytold/designcraft#168 and storytold/filmcraft#179.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyVbT6Vv4BwWZ4hY8973qD
2026-10-08 18:58:22 -06:00
Vikram Bhaskaran 983846a054 Use fontconfig for Linux system fonts 2026-10-09 11:12:26 +11:00
Gary GunnandClaude Haiku 5.5 4c7ffc5f03 Keep the clip context menu open while moving onto it
The clip menu was attached at the end of lane_interaction, which returns
early when the lane has no pointer position. Once the menu opened, the
pointer sat over the popup, the lane lost its hover, and the menu was
never drawn again, so it vanished before Delete could be clicked.

Attach the menu before the early return and remember the right-clicked
clip so it keeps its target. Stale clip ids are dropped if the clip no
longer exists. Adds a kittest regression test covering right-click,
move onto the menu, and Delete.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-08 22:25:48 +01:00
Gary GunnandClaude Haiku 5.5 b3669c767e Fix input monitoring pitch and dropouts when input and session rates differ
The monitor ring fed raw input-device samples to the output callback as if
they were at the session rate. A 16 kHz or 44.1 kHz input played back too
fast (higher pitch) and ran dry between bursts, so it sounded quiet.

InputRing now knows the input rate and converts to the session rate with
linear interpolation in pop_into, allocation-free and non-blocking. The
buffer cap is computed once in new(). Tests cover rate up-conversion,
down-conversion, equal rates and underrun.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-08 22:18:32 +01:00
Gary GunnandClaude Haiku 5.5 9701f8e9a5 Match tempo dialog limits to the engine and test its action
The tempo-change dialog clamped BPM to 1..=999, but the engine accepts
5..=1000. A 1000 BPM tempo was silently changed to 999 on OK, and values
below 5 were rejected after the dialog had closed.

Use the engine's limits, and move the OK action into tempo_change_action
so a test can check it runs a real command with the position. The test
would have caught the original bad command id.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-08 21:42:02 +01:00
Gary GunnandClaude Haiku 5.5 368c3df276 Make the Click option audible: metronome in live playback
Options > Click set a flag but nothing rendered sound, so a click track
was silent. The mix engine now synthesizes a short blip on every beat of
the tempo map (accent on beat 1), using the click.* settings from
Setup > Click/Countoff, and sums it after the master fader.

The metronome is opt-in per engine: only live playback enables it.
Offline renders (bounces, stems, exports) leave it off so the click is
never printed into a file.

Also: midi_to_hz moves from the engine demo into soundcraft-dsp so the
mix and demo share one tuning formula.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-08 21:26:40 +01:00
Gary GunnandClaude Haiku 5.5 c8437c5c61 Fix tempo change from the tempo ruler not saving
Double-clicking the tempo ruler opened a number dialog whose command was
built as "event.tempo@<position>". No such command exists, so OK failed
and nothing changed. The position was also never passed, so a change
would have landed at the session start.

Add a TempoChange dialog that carries the position and runs
event.tempo with bpm and at. Add an engine test that a tempo change at a
position is applied there.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-08 21:24:47 +01:00
ebbicottandClaude Opus 5.5 ed47ec0fbe Instrument picker: choose an instrument track's instrument and open it
There was no way in the app to choose a CLAP, VST3 or Audio Units instrument,
or to open an instrument's editor:
- the insert menus leave instruments out;
- an instrument was only set by track.new;
- the Mix window's INSTRUMENT row looked for instrument inserts rather than the
  track's instrument;
- plugin windows opened only for insert slots.

Changes:
- mix.instrument {track?, plugin} sets an instrument track's instrument, with
  the plugin's default parameters, and is undoable. It refuses effects and
  other track kinds.
- The INST label on an instrument track's header opens an instrument menu: the
  current instrument and its editor, then the built-in, CLAP, VST3 and Audio
  Units instruments, by vendor.
- The Mix window's INSTRUMENT row shows the track's instrument. A click opens a
  hosted instrument's plugin window, and a right-click changes it.
- Plugin windows open for the instrument slot, with only the editor button,
  because the bypass, preset and parameter commands address insert slots.
  They no longer derive a slot letter for the instrument slot.

Closes #22

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:40:37 -04:00
ebbicottandClaude Opus 5.5 2a8c60922f Plugin menus: hosted plugins by vendor in scrollable lists; Audio Units insert
The Mix window's insert menu listed each format's plugins as one flat column.
With many installed (53 VST3 and 95 Audio Units effects on a composer's Mac)
it ran past the bottom of the screen and egui menus don't scroll, so the lower
entries couldn't be reached. CLAP, VST3 and Audio Units now open one submenu
per vendor, as Pro Tools does when plug-in menus are organized by manufacturer,
and every list scrolls within the screen.

Choosing an Audio Unit did nothing: the engine's plugin lookup, which
mix.insert uses, covered built-in, CLAP and VST3 plugins only, so "au:" ids were
"unknown plugin" and the UI dropped the error. The lookup now includes Audio
Units (the engine depends on soundcraft-au-host, like the mixer and the UI), and
the menu shows a failed insert in the status bar.

Closes #21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:39:23 -04:00
ebbicottandClaude Opus 5.5 ae2362752c Shut plugin hosting down in on_exit: ⌘Q exits from inside the event loop
The system Quit (⌘Q, or a quit Apple Event) goes through
-[NSApplication terminate:], which calls exit() from inside the event loop, so
eframe::run_native never returns and the shutdown placed after it never ran.
With HALion Sonic in use, the exit handler then ran its bundleExit too late and
the app crashed on quit ("quit unexpectedly", found in manual testing).

eframe calls on_exit on every way out, so the app now releases its player (and
with it the plugin instances) and shuts plugin hosting down there.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:13:39 -04:00
ebbicottandClaude Opus 5.5 ccd1f995bf Shut plugin hosting down at the end of main, not only from an exit handler
Testing on macOS showed that the exit-handler fallback comes too late for a
plugin that has been used: with HALion Sonic inserted on a track, its bundleExit
segfaults, and both the app (on quit) and soundcraft-cli (after a bounce) exited
with 139. A plain scan was fine. Exit handlers run in reverse order of
registration, so state the plugin set up while in use is presumably torn down
before the host's handler runs.

soundcraft_vst3_host::shutdown() runs the module exits up front, and
soundcraft_engine::shutdown_plugin_hosts() calls it. The app calls that once the
event loop has returned (the player and its plugin instances are gone by then),
and the CLI once its subcommand has. The exit handler stays as the fallback for
other exits, such as test binaries.

The fixture can now set up state lazily the way such plugins do
(VST3_FIXTURE_LATE_STATE), and module_exit_shutdown checks both sides in child
processes: with shutdown() the exit is clean, with only the exit handler the
fixture reports the late teardown. Contract violations now end the process with
status 3 instead of an abort, so that expected failure leaves no crash report.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:28:27 -04:00
MarekSpodBiedry b919713ea7 Fix keyboard shortcuts on Windows and Linux: Ctrl is the Cmd key (#15)
egui reports a held Ctrl as both ctrl and command off macOS, so no Cmd+ shortcut matched and Ctrl+S opened Search (Cmd+Ctrl+S) instead of saving. Off macOS a held Ctrl now stands for the shortcut's Cmd, shortcuts that also need the Mac Control key stay off the keyboard there (Ctrl+X is Cut, not Cmd+Ctrl+X), and the Search chord is macOS only.
2026-10-08 20:06:42 +02:00
ebbicottandClaude Opus 5.5 527f555512 Fix the crash on quit after a VST3 scan: run module exits on the entry thread
vst3-host ran each module's entry (bundleEntry / InitDll / ModuleEntry) when it
loaded a .vst3 and cached the module for the life of the process, but never ran
the matching exit or released the cached factory. At exit() the module's static
destructors then ran on a module that was never shut down. Steinberg's HALion
Sonic segfaults there, so every process that had scanned VST3 plugins crashed on
exit: the app on quit, soundcraft-cli, and the engine, mix and vst3-host unit
tests.

At process exit each module now has its factory released and its exit run,
newest first, on the thread that ran its entry: Native Instruments' Massive X
crashes in bundleExit on any other thread. Loads asked for off the main thread
go to a long-lived module thread, which also runs their exits. Modules with a
live instance or a leaked editor view are left alone, later loads are refused,
and plugin code stays mapped. The exit handler is registered with atexit after
each new module, so it runs ahead of that module's static destructors; it uses
OS thread ids because std::thread::current() panics in exit handlers. A module
whose entry ran but that then fails to load now gets its exit too.

The fixture plugin now checks the module lifecycle and aborts the process when
it is broken. Three harness = false tests cover the exit after use, a live
instance at exit, and a load from a short-lived thread.

Fixes #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:45:32 -04:00
Gert Pellin 5b95e85929 Contributors: credit switch87 as Gert Pellin 2026-10-08 12:27:07 +02:00
Gert Pellin 74926df527 Desktop: write log records to standard error and a rotating log file
The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: the engine's panic report, audio devices
that failed to open or broke, plugins that refused their stored state or
failed to activate, CLAP plugins' own log messages, files that failed to
open. A launch from a desktop menu has no terminal either, so there was
nothing to attach to a bug report.

apps/soundcraft/src/logging.rs (no new dependency):
- standard error plus <settings dir>/logs/soundcraft.log (beside
  ui.json, Autosave/ and Presets/); each launch rotates the previous log
  to soundcraft.1.log and .2.log, so the log of a run that crashed
  survives the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
  (bounded) and written once the file is attached
- default levels: info for soundcraft*, warn for everything else;
  RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
  error as the only sink; a poisoned lock is tolerated
- runs with SOUNDCRAFT_NO_PREFS (agents' test runs) log to standard
  error only, so they don't rotate away the user's own logs

Realtime audio: a record takes a lock and does I/O, which the audio
callback must never do, yet code on the audio thread logs (the cpal
error callbacks, the synth's full event queue, a hosted plugin's failed
process, a CLAP plugin calling the host's log, the panic hook). The cpal
callbacks now mark their thread (soundcraft_mix::mark_audio_thread,
re-exported by soundcraft_playback), and the mix engine carries the mark
into the rayon workers that render strips for it. A record from a marked
thread is not written there: the first is kept in a fixed buffer
(try_lock, no allocation, never waits), the rest are counted, and the UI
thread writes them every frame (any other thread before its own next
record).

The logger is installed first in main (before the engine's panic hook)
and the file is opened after argument parsing (--version leaves no
file). The settings directory moves out of prefs_path into config_dir.
The control server's eprintln!s go through the logger (the
SOUNDCRAFT_CONTROL_PORT line on standard output stays). Documented in
README.md (Getting started > Logs) and docs/architecture.md (Audio).
2026-10-08 12:25:49 +02:00
reoniazx b44aaade42 macOS: move application menus to the system menu bar 2026-10-08 14:10:03 +08:00
73 changed files with 4480 additions and 358 deletions
+1
View File
@@ -9,3 +9,4 @@ plan/
/target-*/
/craft-fonts/
plans/
.DS_Store
+1
View File
@@ -38,6 +38,7 @@ and, at runtime, the operating system's own UI fonts when available (never redis
| `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 |
| `assets/app-icon/soundcraft-macos-512.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft.icns` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
Generated
+99 -18
View File
@@ -464,6 +464,9 @@ name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
dependencies = [
"serde_core",
]
[[package]]
name = "block2"
@@ -788,6 +791,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@@ -825,6 +837,23 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "dark-light"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f66b71ac6b73812b0bf51cda8dd646a268fbb38623f45b2b7907593c323f745"
dependencies = [
"futures-channel",
"futures-core",
"objc2 0.6.5",
"objc2-foundation 0.3.2",
"wasm-bindgen",
"web-sys",
"windows-sys 0.61.2",
"winreg",
"zbus",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
@@ -1290,6 +1319,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -1830,6 +1868,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.13.2",
"serde",
"unicode-segmentation",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -2017,6 +2066,25 @@ dependencies = [
"pxfm",
]
[[package]]
name = "muda"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -3361,11 +3429,12 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"eframe",
"egui",
"log",
"muda",
"rfd",
"serde_json",
"soundcraft-engine",
@@ -3376,7 +3445,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"serde",
@@ -3386,7 +3455,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"proptest",
@@ -3398,7 +3467,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"serde_json",
@@ -3408,7 +3477,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"clap-sys",
"libloading",
@@ -3420,7 +3489,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3431,7 +3500,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"proptest",
@@ -3442,12 +3511,13 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"proptest",
"serde",
"serde_json",
"soundcraft-au-host",
"soundcraft-audio-io",
"soundcraft-clap-host",
"soundcraft-dsp",
@@ -3462,7 +3532,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"proptest",
"serde",
@@ -3472,7 +3542,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"log",
"rayon",
@@ -3488,7 +3558,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"proptest",
"serde",
@@ -3501,7 +3571,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"cpal",
"log",
@@ -3514,7 +3584,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"proptest",
"serde",
@@ -3524,8 +3594,9 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"dark-light",
"egui",
"egui_kittest",
"image",
@@ -3549,7 +3620,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"image",
"proptest",
@@ -3560,7 +3631,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"libloading",
"log",
@@ -3575,7 +3646,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"eframe",
"egui",
@@ -5035,6 +5106,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
]
[[package]]
name = "winresource"
version = "0.1.31"
@@ -5122,7 +5203,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde_json",
]
+2 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.3.0"
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -44,6 +44,7 @@ cpal = "0.16"
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
dark-light = "3"
rfd = "0.17"
image = { version = "0.25", default-features = false, features = ["png"] }
+43
View File
@@ -158,6 +158,28 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
new tracks. **Setup › Keyboard Shortcuts** lists them all.
### Logs
The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
settings directory, beside `ui.json`, `Autosave/` and `Presets/` (Linux `~/.config/soundcraft/logs/`,
or `$XDG_CONFIG_HOME/soundcraft/logs/`; macOS `~/Library/Application Support/SoundCraft/logs/`;
Windows `%APPDATA%\SoundCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so
this file is what to attach to a bug report: the engine's panic report, audio devices that failed to
open or broke, plugins that refused their stored state, CLAP plugins' own messages and files that
failed to open land there. Each launch moves the previous log to `soundcraft.1.log` (and that one to
`soundcraft.2.log`), so the log of a run that crashed survives the next start. The file stops
growing at 16 MiB. `--version` writes no file, and runs with `SOUNDCRAFT_NO_PREFS` log to standard
error only.
| Variable | Effect |
|---|---|
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for SoundCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,soundcraft_mix=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`soundcraft*=debug`). |
| `SOUNDCRAFT_NO_PREFS` | No preferences read or written and no log file (agents' test runs). |
The realtime audio thread never writes a record itself: the logger keeps the first one it logs
(without waiting or allocating), counts the rest, and the UI thread writes them. The logger is
`apps/soundcraft/src/logging.rs`. The web app logs to the browser console instead.
## For agents and scripts
Everything you can click is also a command with an id and JSON parameters, and the same commands
@@ -184,6 +206,12 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo
See [`docs/control-protocol.md`](docs/control-protocol.md) and [`docs/mcp.md`](docs/mcp.md).
`soundcraft-cli commands` lists every command.
Open **SoundCraft › Session Audio Health** to check loaded audio availability, sample-rate
mismatches and clip source bounds across all playlists, including alternate takes. The report
offers a refresh button and suggests how to resolve each issue. Scripts can obtain the same JSON
report with `soundcraft-cli run --demo --cmd 'session.audio_health={}'`. This checks loaded media,
not files on disk, signal levels, plugins or routing.
## How it's built
SoundCraft is a Cargo workspace of small crates with strict layering: nothing below the UI knows
@@ -228,6 +256,21 @@ very welcome, in the issues or on Discord.
Installers and executables are code-signed.
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
PowerShell, from the folder containing the executable:
```powershell
$env:WGPU_BACKEND = "vulkan"
& .\soundcraft.exe
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
```
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
### macOS
| Build | File | Notes |
+1 -1
View File
@@ -29,7 +29,7 @@ the honest status: what works today, what is missing, and how far we are.
| Elastic Audio / TCE / Beat Detective | Pitch-preserving warp on Elastic tracks, TCE to timeline, conform to tempo, Beat Detective and Identify Beat windows; audio-to-MIDI pitch detection | 45 % | 15 |
| Surround | Main and bus formats from stereo to 9.1.6 and Ambisonics, surround panner with divergence/centre/LFE/height, ITU fold-down, multichannel bounce, Renderer window, object/bed routing | 60 % | 12 |
| Video | Picture track with thumbnails, Video and Video Universe windows, H.264 (Baseline/Main/High), ProRes (all flavours) and Motion JPEG decoders in pure Rust, sync offset, relinking. Missing: HEVC/AV1/DNx, timecode-track placement, video export | 55 % | 15 |
| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog, floating windows, dialogs (every file command has a path prompt), command palette | 70 % | 20 |
| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog (macOS system menu bar), floating windows, dialogs (every file command has a path prompt), command palette | 70 % | 20 |
| Agent control | CLI, JSON control channel, MCP server (headless + bridged), offscreen UI renders | 90 % (ahead of the incumbent) | 3 |
| Release engineering | Signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm/tar/Flatpak, FreeBSD, Web/WASM on every push to `release` | 80 % | 3 |
+86 -14
View File
@@ -37,6 +37,17 @@ fn fail(msg: impl std::fmt::Display) -> ExitCode {
ExitCode::FAILURE
}
/// The usage block of the module doc comment, up to its closing code fence.
fn help_text() -> String {
include_str!("main.rs")
.lines()
.skip(3)
.take_while(|l| l.trim_end() != "//! ```")
.map(|l| l.trim_start_matches("//! "))
.collect::<Vec<_>>()
.join("\n")
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(cmd) = args.first().cloned() else {
@@ -44,13 +55,13 @@ fn main() -> ExitCode {
return ExitCode::FAILURE;
};
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
match cmd.as_str() {
let code = match cmd.as_str() {
"--version" | "-V" | "version" => {
outln!("SoundCraft CLI {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
"--help" | "-h" | "help" => {
outln!("{}", include_str!("main.rs").lines().skip(3).take(14).map(|l| l.trim_start_matches("//! ")).collect::<Vec<_>>().join("\n"));
outln!("{}", help_text());
ExitCode::SUCCESS
}
"info" => info(&rest),
@@ -69,15 +80,19 @@ fn main() -> ExitCode {
ExitCode::SUCCESS
}
other => fail(format!("unknown subcommand `{other}`")),
}
};
// The subcommand's engine and its plugin instances are gone by now.
soundcraft_engine::shutdown_plugin_hosts();
code
}
fn info(args: &[String]) -> ExitCode {
let Some(path) = args.first() else { return fail("info FILE") };
if path.ends_with(".scraft") {
let mut e = Engine::default();
if let Err(err) = soundcraft_engine::io::open_session(&mut e, path) {
return fail(err);
match soundcraft_engine::io::open_session(&mut e, path) {
Ok(missing) => warn_missing(&missing),
Err(err) => return fail(err),
}
outln!("{}", soundcraft_engine::inspect::session_text(&e));
return ExitCode::SUCCESS;
@@ -115,10 +130,8 @@ fn convert(args: &[String]) -> ExitCode {
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
}
let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
let format = match out_ext.as_str() {
"aif" | "aiff" => soundcraft_audio_io::FileFormat::Aiff,
"flac" => soundcraft_audio_io::FileFormat::Flac,
_ => soundcraft_audio_io::FileFormat::Wav,
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
};
let bit_depth = match arg_value(args, "--bit-depth").as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
@@ -135,10 +148,22 @@ fn convert(args: &[String]) -> ExitCode {
}
}
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
fn missing_warnings(missing: &[String]) -> Vec<String> {
missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
}
fn warn_missing(missing: &[String]) {
for line in missing_warnings(missing) {
eprintln!("{line}");
}
}
fn load_engine(args: &[String]) -> Result<Engine, String> {
if let Some(p) = arg_value(args, "--in") {
let mut e = Engine::default();
soundcraft_engine::io::open_session(&mut e, &p).map_err(|e| e.to_string())?;
let missing = soundcraft_engine::io::open_session(&mut e, &p).map_err(|e| e.to_string())?;
warn_missing(&missing);
Ok(e)
} else if args.iter().any(|a| a == "--demo" || a == "--sample") {
Ok(soundcraft_engine::demo::demo_engine())
@@ -178,6 +203,14 @@ fn time_arg(s: &str) -> Value {
s.parse::<f64>().map_or_else(|_| json!(s), |f| json!({"seconds": f}))
}
/// `ID` or `ID=JSON` → command id and params; malformed JSON is an error, not `{}`.
fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> {
match spec.split_once('=') {
Some((id, p)) => serde_json::from_str::<Value>(p).map(|v| (id.to_string(), v)).map_err(|err| format!("{id}: bad JSON: {err}")),
None => Ok((spec.to_string(), json!({}))),
}
}
fn run(args: &[String]) -> ExitCode {
let mut e = match load_engine(args) {
Ok(e) => e,
@@ -188,9 +221,9 @@ fn run(args: &[String]) -> ExitCode {
if args.get(i).is_some_and(|a| a == "--cmd")
&& let Some(spec) = args.get(i + 1)
{
let (id, params) = match spec.split_once('=') {
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).unwrap_or(json!({}))),
None => (spec.clone(), json!({})),
let (id, params) = match parse_cmd_spec(spec) {
Ok(v) => v,
Err(err) => return fail(err),
};
match e.execute(&id, &params) {
Ok(v) => outln!("{id}: {v}"),
@@ -270,7 +303,13 @@ fn app(args: &[String]) -> ExitCode {
.map(|(_, a)| a)
.collect();
let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") };
let params = positional.get(1).and_then(|p| serde_json::from_str::<Value>(p).ok()).unwrap_or(json!({}));
let params = match positional.get(1) {
Some(p) => match serde_json::from_str::<Value>(p) {
Ok(v) => v,
Err(err) => return fail(format!("{method}: bad JSON: {err}")),
},
None => json!({}),
};
let mut r = Remote::new(&port);
// A bare command id is shorthand for engine.execute.
let (m, p) = if method.contains('.')
@@ -336,3 +375,36 @@ fn parity(args: &[String]) -> ExitCode {
}
ExitCode::SUCCESS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_media_gets_one_warning_each() {
assert!(missing_warnings(&[]).is_empty());
let w = missing_warnings(&["a.wav".to_string(), "b.wav: decode failed".to_string()]);
assert_eq!(w, vec!["warning: missing media: a.wav", "warning: missing media: b.wav: decode failed"]);
}
#[test]
fn cmd_spec_parses_id_and_json() {
assert_eq!(parse_cmd_spec("a.b").unwrap(), ("a.b".to_string(), json!({})));
assert_eq!(parse_cmd_spec("a.b={\"x\":1}").unwrap(), ("a.b".to_string(), json!({"x": 1})));
}
#[test]
fn cmd_spec_rejects_truncated_json() {
let err = parse_cmd_spec("a.b={\"x\":").unwrap_err();
assert!(err.starts_with("a.b: bad JSON"), "{err}");
}
#[test]
fn help_text_is_only_the_usage_block() {
let help = help_text();
assert!(help.starts_with("soundcraft-cli info FILE"));
assert!(help.ends_with("soundcraft-cli plugins list built-in plugins"));
assert!(!help.contains("```"));
assert!(!help.contains("#![") && !help.contains("use serde_json"));
}
}
+21
View File
@@ -15,6 +15,27 @@
</head>
<body>
<canvas id="soundcraft_canvas"></canvas>
<script>
// cpal (wasm) builds the output stream at startup, before any user gesture, so the
// WebAudio AudioContext stays 'suspended': no audio is heard and the transport clock
// (driven by the audio callback) never advances. Capture the AudioContext cpal creates
// and resume it on the first user gesture. Runs before the wasm module is instantiated.
(function () {
var Orig = window.AudioContext || window.webkitAudioContext;
if (!Orig) return;
var ctxs = [];
function Wrapped(opts) { var c = new Orig(opts); ctxs.push(c); return c; }
Wrapped.prototype = Orig.prototype;
window.AudioContext = Wrapped;
if (window.webkitAudioContext) window.webkitAudioContext = Wrapped;
function resumeAll() {
ctxs.forEach(function (c) { if (c.state === 'suspended') { c.resume().catch(function () {}); } });
}
['pointerdown', 'mousedown', 'touchstart', 'keydown'].forEach(function (ev) {
window.addEventListener(ev, resumeAll, { capture: true });
});
})();
</script>
<div id="soundcraft_loading">Loading SoundCraft&hellip;</div>
</body>
</html>
+3
View File
@@ -25,3 +25,6 @@ rfd.workspace = true
[build-dependencies]
# Icon + VERSIONINFO for soundcraft.exe; build.rs only uses it when the target is Windows.
winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.19.2", default-features = false }
+3 -3
View File
@@ -13,12 +13,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("soundcraft: control server failed to bind 127.0.0.1:{port}: {e}");
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port);
eprintln!("soundcraft: control server listening on 127.0.0.1:{actual}");
log::info!("control server listening on 127.0.0.1:{actual}");
println!("SOUNDCRAFT_CONTROL_PORT={actual}");
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
@@ -28,7 +28,7 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
});
if let Err(e) = spawned {
eprintln!("soundcraft: control thread failed: {e}");
log::error!("control thread failed: {e}");
}
rx
}
+47
View File
@@ -0,0 +1,47 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
/// 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>) {
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
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
// 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);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_backends(backend_override: Option<Backends>) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
create.instance_descriptor.backends
}
#[test]
fn windows_default_initializes_only_dx12() {
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
assert_eq!(configured_backends(None), Backends::DX12);
}
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
assert_eq!(configured_backends(Some(backends)), backends);
}
}
}
+796
View File
@@ -0,0 +1,796 @@
//! The desktop app's logger: `log::` records go to standard error and to a log file.
//!
//! Without a logger every `log::warn!`/`log::error!` in the workspace (the engine's panic report,
//! audio devices that failed to open or broke, plugins that refused their stored state or failed
//! to activate, CLAP plugins' own log messages, decode errors on import) vanished. A launch from a
//! desktop menu has no terminal, so the file is what a bug report can attach:
//! `<settings dir>/logs/soundcraft.log`, beside `ui.json`, `Autosave/` and `Presets/`. Each start
//! moves the previous log to `soundcraft.1.log` (and that one to `.2`), so the log of a run that
//! crashed survives the next launch. Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test
//! runs) log to standard error only, so they don't rotate away the user's own logs.
//!
//! Levels: `info` for SoundCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
//! `warn,soundcraft_mix=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
//! that starts with it (`soundcraft*=debug`).
//!
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
//!
//! Realtime audio: a record takes a lock and writes to standard error and a file, which the audio
//! callback must never do. Code that runs on the audio thread logs anyway (the cpal error callback,
//! the synth's full event queue, a hosted plugin's failed `process`, a CLAP plugin calling the
//! host's `log` from its audio thread, the panic hook). So the cpal callbacks mark their thread
//! (`soundcraft_playback::mark_audio_thread`; the mix engine carries the mark into the worker
//! threads that render strips for it), and a record from a marked thread is not written
//! there: the first one is kept in a fixed buffer (`try_lock`, no allocation, never waits) and the
//! rest are only counted (an atomic). Another thread writes them out: the UI thread every frame
//! ([`AppLogger::report_audio_thread`]) and any other thread before its own next record.
use std::fmt::Write as _;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::SystemTime;
use log::LevelFilter;
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "soundcraft.log";
/// The log directory inside the settings directory.
pub const LOG_DIR: &str = "logs";
/// How many previous logs are kept (`soundcraft.1.log` … `soundcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The built-in filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,soundcraft*=info";
/// The longest message kept from the audio thread (longer ones are cut, marked with `…`).
pub const REALTIME_MESSAGE_BYTES: usize = 480;
/// The longest target kept from the audio thread.
const REALTIME_TARGET_BYTES: usize = 96;
/// The thread name shown for records from the audio thread.
const AUDIO_THREAD: &str = "audio";
/// Per-target level filter parsed from env_logger-style directives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filter {
default: LevelFilter,
directives: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
/// directive names log errors only.
pub fn parse(spec: &str) -> Filter {
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('=') {
Some((name, level)) => {
let name = name.trim();
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
f.directives.push((name.to_owned(), level));
}
}
None => match parse_level(part) {
Some(level) => f.default = level,
// A bare target name: everything from it (env_logger does the same).
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
},
}
}
f
}
/// The level that applies to `target` (the most specific matching directive wins).
pub fn level_for(&self, target: &str) -> LevelFilter {
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
}
/// The most verbose level any target can reach (for `log::set_max_level`).
pub fn max(&self) -> LevelFilter {
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
}
}
fn parse_level(s: &str) -> Option<LevelFilter> {
s.trim().parse().ok()
}
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
/// `name*` matches every target starting with `name`.
fn matches(name: &str, target: &str) -> bool {
match name.strip_suffix('*') {
Some(prefix) => target.starts_with(prefix),
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
/// clock can't overflow).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("soundcraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, line: &str) {
if self.file.is_none() {
if self.capped {
return;
}
if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file`, first the lines kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
/// There will be no log file: forget the lines kept for it and keep no more.
pub fn no_file(&mut self) {
self.file = None;
self.pending = Vec::new();
self.dropped = 0;
self.capped = true;
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink (and
// stop buffering, since no file will come).
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// Text formatted into a fixed buffer: no allocation, cut at a character boundary when full.
struct Bounded<const N: usize> {
buf: [u8; N],
len: usize,
cut: bool,
}
impl<const N: usize> Bounded<N> {
fn new() -> Self {
Bounded { buf: [0; N], len: 0, cut: false }
}
fn as_str(&self) -> &str {
// Only whole characters are ever copied in, so this never fails.
self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("")
}
}
impl<const N: usize> std::fmt::Write for Bounded<N> {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let room = N.saturating_sub(self.len);
let mut n = s.len().min(room);
while !s.is_char_boundary(n) {
n = n.saturating_sub(1);
}
if let (Some(dst), Some(src)) = (self.buf.get_mut(self.len..self.len.saturating_add(n)), s.as_bytes().get(..n)) {
dst.copy_from_slice(src);
self.len = self.len.saturating_add(n);
}
if n < s.len() {
self.cut = true;
}
Ok(())
}
}
/// The first record the audio thread logged since the last report.
struct Held {
at: SystemTime,
level: log::Level,
target: Bounded<REALTIME_TARGET_BYTES>,
message: Bounded<REALTIME_MESSAGE_BYTES>,
}
/// Records from the realtime audio thread, waiting for another thread to write them.
#[derive(Default)]
struct Realtime {
/// Records logged on the audio thread since the last report (the held one included).
records: AtomicUsize,
/// The first of them; the audio thread only `try_lock`s it.
held: Mutex<Option<Held>>,
}
impl Realtime {
/// Audio thread: count `record` and keep it if it is the first. Never waits, never allocates.
fn keep(&self, record: &log::Record<'_>) {
self.records.fetch_add(1, Ordering::Relaxed);
if let Ok(mut held) = self.held.try_lock()
&& held.is_none()
{
let mut target = Bounded::new();
let _ = target.write_str(record.target());
let mut message = Bounded::new();
let _ = write!(message, "{}", record.args());
*held = Some(Held { at: SystemTime::now(), level: record.level(), target, message });
}
}
/// Another thread: the lines to write for what the audio thread logged (empty when nothing).
fn take_lines(&self) -> String {
if self.records.load(Ordering::Relaxed) == 0 {
return String::new();
}
let held = self.held.lock().unwrap_or_else(PoisonError::into_inner).take();
let records = self.records.swap(0, Ordering::Relaxed);
let mut lines = String::new();
if let Some(h) = &held {
let mut message = h.message.as_str().to_owned();
if h.message.cut {
message.push('…');
}
lines.push_str(&format_line(&timestamp(h.at), h.level, AUDIO_THREAD, h.target.as_str(), &message));
}
let more = records.saturating_sub(usize::from(held.is_some()));
if more > 0 {
let thread = std::thread::current();
let note = format!("{more} more log records from the realtime audio thread were not written (only the first is kept)");
lines.push_str(&format_line(&timestamp(SystemTime::now()), log::Level::Warn, thread.name().unwrap_or("?"), module_path!(), &note));
}
lines
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
realtime: Realtime,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), realtime: Realtime::default() }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
/// An unusable directory leaves standard error as the only sink ([`AppLogger::no_file`]).
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
match rotate(dir) {
Ok((path, file)) => {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
Ok(path)
}
Err(e) => {
self.no_file();
Err(format!("{}: {e}", dir.display()))
}
}
}
/// No log file this run (no settings directory, `SOUNDCRAFT_NO_PREFS`, or the directory can't
/// be used): stop keeping records for one.
pub fn no_file(&self) {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
}
/// Write what the audio thread logged since the last report (never call it on the audio thread).
pub fn report_audio_thread(&self) {
let lines = self.realtime.take_lines();
if !lines.is_empty() {
self.emit(&lines);
}
}
/// Standard error, then the sink; the lock covers one write of already formatted text.
fn emit(&self, text: &str) {
if self.stderr {
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
let _ = std::io::stderr().write_all(text.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(text);
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
if soundcraft_playback::on_audio_thread() {
self.realtime.keep(record);
return;
}
self.report_audio_thread();
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
let thread = std::thread::current();
let line =
format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
self.emit(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] or [`AppLogger::no_file`] once the
/// settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
#[cfg(test)]
mod tests {
use super::*;
use log::Log;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("soundcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &str) {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
/// Run `f` on a thread the playback crate marks as the realtime audio thread.
fn on_audio_thread<T: Send>(f: impl FnOnce() -> T + Send) -> T {
std::thread::scope(|s| {
s.spawn(|| {
soundcraft_playback::mark_audio_thread();
f()
})
.join()
.expect("audio thread")
})
}
#[test]
fn the_default_filter_shows_soundcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("soundcraft"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft_playback"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft_clap_host::ffi"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("cpal"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,soundcraft_mix=trace,soundcraft_mix::pdc=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("soundcraft_mix::render"), LevelFilter::Trace);
assert_eq!(f.level_for("soundcraft_mix::pdc"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_ui_egui::mix_window"), LevelFilter::Debug);
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_engine"), LevelFilter::Warn);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive; the last bare level wins.
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "a".repeat(100_000);
let specs = [
"",
",,,",
"=",
"==",
"=debug",
"soundcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=debug",
"a=b=c",
"warn,,soundcraft=DEBUG",
" \t ",
"\u{0}",
"é*=trace",
&long,
];
for spec in specs {
let f = Filter::parse(spec);
for target in ["soundcraft", "", "🦀", "é", "a::b"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,soundcraft=DEBUG").level_for("soundcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("soundcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("soundcraft.2.log")), "run 2");
assert!(!dir.join("soundcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
assert!(rotate(&dir.join("below")).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line(
"2026-10-08T07:59:17.728Z",
log::Level::Warn,
"main",
"soundcraft_mix",
"eq_7band: the plugin did not accept its stored state"
),
"2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_max_pending_lines_drop_nothing() {
let dir = temp_dir("exact");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "soundcraft", "SoundCraft starting");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Debug, "soundcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("soundcraft: SoundCraft starting")), "{text}");
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("soundcraft_engine").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// A second launch rotates: the first log becomes `.1`.
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
assert_eq!(read(&dir.join("soundcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
std::thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..100 {
record(&logger, log::Level::Info, "soundcraft", "from a worker");
}
});
}
});
let text = read(&path);
assert_eq!(text.lines().count(), 400);
assert!(text.lines().all(|l| l.ends_with("soundcraft: from a worker")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
record(&logger, log::Level::Info, "soundcraft", "kept for a file");
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
assert!(e.contains(&dir.display().to_string()), "{e}");
record(&logger, log::Level::Error, "x", "still fine");
let sink = logger.sink.lock().expect("sink");
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
drop(sink);
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_run_without_a_log_file_keeps_nothing_for_one() {
let logger = AppLogger::new(Filter::parse("info"), false);
record(&logger, log::Level::Info, "soundcraft", "early");
logger.no_file();
record(&logger, log::Level::Info, "soundcraft", "late");
let sink = logger.sink.lock().expect("sink");
assert!(sink.file.is_none() && sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn a_poisoned_lock_does_not_stop_logging() {
let dir = temp_dir("poison");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = logger.sink.lock();
panic!("poison the sink");
}));
assert!(logger.sink.is_poisoned());
record(&logger, log::Level::Info, "soundcraft", "after the panic");
assert!(read(&path).contains("after the panic"));
let _ = std::fs::remove_dir_all(&dir);
}
// Realtime audio: the audio thread never takes the sink lock or touches the file.
#[test]
fn an_audio_thread_record_never_waits_for_the_sink() {
let dir = temp_dir("rt-nowait");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
// Another thread holds the sink (a slow disk, say) while the audio thread logs.
let held = logger.sink.lock().expect("sink");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::scope(|s| {
s.spawn(|| {
soundcraft_playback::mark_audio_thread();
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
let _ = tx.send(());
});
let done = rx.recv_timeout(Duration::from_secs(10)).is_ok();
drop(held);
assert!(done, "the audio thread blocked on the sink lock");
});
assert_eq!(read(&path), "", "nothing is written from the audio thread itself");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_first_audio_thread_record_is_written_later_with_a_count_of_the_rest() {
let dir = temp_dir("rt-report");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let path = logger.attach_dir(&dir).expect("attach");
on_audio_thread(|| {
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
for _ in 0..41 {
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: still unplugged");
}
// Filtered out: neither kept nor counted.
record(&logger, log::Level::Debug, "soundcraft_clap_host::plugin", "process failed");
});
assert_eq!(read(&path), "");
logger.report_audio_thread();
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines.first().is_some_and(|l| l.contains(" WARN [audio] soundcraft_playback: audio stream error: device unplugged")), "{text}");
assert!(lines.get(1).is_some_and(|l| l.contains("41 more log records from the realtime audio thread were not written")), "{text}");
// Reported once.
logger.report_audio_thread();
assert_eq!(read(&path), text);
// The next record from any other thread reports what the audio thread kept since, first.
on_audio_thread(|| record(&logger, log::Level::Error, "soundcraft_clap_host::ffi", "clap plugin: out of voices"));
record(&logger, log::Level::Info, "soundcraft", "UI thread");
let text = read(&path);
let tail: Vec<&str> = text.lines().skip(2).collect();
assert_eq!(tail.len(), 2, "{text}");
assert!(tail.first().is_some_and(|l| l.contains(" ERROR [audio] soundcraft_clap_host::ffi: clap plugin: out of voices")), "{text}");
assert!(tail.get(1).is_some_and(|l| l.ends_with("soundcraft: UI thread")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_long_audio_thread_record_is_cut_at_a_character_boundary() {
let dir = temp_dir("rt-long");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
let long = "é".repeat(REALTIME_MESSAGE_BYTES);
on_audio_thread(|| record(&logger, log::Level::Warn, "soundcraft", &long));
logger.report_audio_thread();
let text = read(&path);
let message = text.split("soundcraft: ").nth(1).expect("message");
assert!(message.ends_with("…\n"), "{text}");
assert!(message.len() <= REALTIME_MESSAGE_BYTES + "…\n".len(), "{}", message.len());
assert!(message.trim_end_matches(['…', '\n']).chars().all(|c| c == 'é'));
let _ = std::fs::remove_dir_all(&dir);
}
}
+97 -12
View File
@@ -5,20 +5,40 @@
//! `--control <port>` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` →
//! `{"id":1,"ok":true,"result":…}`. See `soundcraft_ui_egui::control` for the methods.
//!
//! `log` records go to standard error and `<settings dir>/logs/soundcraft.log` (`RUST_LOG` sets the
//! levels); see [`logging`].
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp, UiState};
use std::sync::Arc;
struct App(SoundApp);
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
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.
if let Some(logger) = self.1 {
logger.report_audio_thread();
}
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.2 {
menu.process(&mut self.0);
}
self.0.logic(ctx);
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.2 {
menu.sync(&self.0);
}
// Files dropped on the window: sessions open, audio/MIDI import.
let dropped: Vec<String> =
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect());
@@ -28,7 +48,18 @@ impl eframe::App for App {
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
let title = format!("{} — SoundCraft", self.0.engine.session().name);
// 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 {
format!(
"{}{} — SoundCraft — {}",
self.0.engine.session().name,
if self.0.engine.is_dirty() { " *" } else { "" },
if self.0.ui.window == soundcraft_ui_egui::MainWindow::Edit { "Edit" } else { "Mix" }
)
} else {
format!("{} — SoundCraft", self.0.engine.session().name)
};
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@@ -39,6 +70,13 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
save_prefs(&self.0.ui);
if let Some(logger) = self.1 {
logger.report_audio_thread();
}
// The system Quit (⌘Q) ends the process from inside the event loop, so `run_native` never
// returns: release the player's plugin instances and shut plugin hosting down here.
self.0.player = None;
soundcraft_engine::shutdown_plugin_hosts();
}
}
@@ -57,8 +95,9 @@ fn open_path(app: &mut SoundApp, p: &str) {
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
let base = if cfg!(target_os = "macos") {
/// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here.
fn config_dir() -> Option<std::path::PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SoundCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SoundCraft"))
@@ -67,12 +106,20 @@ fn prefs_path() -> Option<std::path::PathBuf> {
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join("soundcraft"))
};
base.map(|b| b.join("ui.json"))
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
config_dir().map(|b| b.join("ui.json"))
}
/// Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("SOUNDCRAFT_NO_PREFS").is_none()
}
fn load_prefs() -> Option<UiState> {
if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() {
if !prefs_enabled() {
return None;
}
let bytes = std::fs::read(prefs_path()?).ok()?;
@@ -80,7 +127,7 @@ fn load_prefs() -> Option<UiState> {
}
fn save_prefs(ui: &UiState) {
if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() {
if !prefs_enabled() {
return;
}
if let Some(p) = prefs_path() {
@@ -132,14 +179,14 @@ fn app_icon() -> Option<egui::IconData> {
}
fn main() -> eframe::Result {
// First, so every start-up record (and the engine's panic hook, installed with the first
// Engine) is captured; see `logging`.
let logger = logging::install();
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("SoundCraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
if std::env::var_os("RUST_LOG").is_none() {
// Quiet by default; `RUST_LOG=info` for more.
}
let demo = args.iter().any(|a| a == "--demo" || a == "--sample");
let no_audio = args.iter().any(|a| a == "--no-audio");
let control_port: Option<u16> = args
@@ -154,6 +201,24 @@ fn main() -> eframe::Result {
.filter(|(i, a)| !a.starts_with("--") && !(i > &0 && args.get(i - 1).is_some_and(|p| p == "--control")))
.map(|(_, a)| a.clone())
.collect();
log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
// The log file lives in the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first. Runs without
// preferences (agents' test runs) log to standard error only, so they don't rotate away the
// user's own logs.
if let Some(logger) = logger {
match config_dir() {
_ if !prefs_enabled() => logger.no_file(),
Some(dir) => match logger.attach_dir(&dir.join(logging::LOG_DIR)) {
Ok(path) => log::info!("log file {}", path.display()),
Err(e) => log::warn!("no log file: {e}"),
},
None => {
logger.no_file();
log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)");
}
}
}
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let mut options = eframe::NativeOptions {
@@ -168,6 +233,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).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
eframe::run_native(
"SoundCraft",
options,
@@ -186,7 +254,24 @@ fn main() -> eframe::Result {
for f in &files {
open_path(&mut app, f);
}
Ok(Box::new(App(app)))
// If the system menu bar cannot be built, keep the egui menu bar rather than fail to start.
#[cfg(target_os = "macos")]
let menu = match native_menu::NativeMenu::new(&app, &cc.egui_ctx) {
Ok(m) => {
app.native_menu_bar = true;
Some(m)
}
Err(e) => {
log::warn!("system menu bar unavailable, using the in-window menu bar: {e}");
None
}
};
Ok(Box::new(App(
app,
logger,
#[cfg(target_os = "macos")]
menu,
)))
}),
)
}
+126
View File
@@ -0,0 +1,126 @@
//! macOS system menu bar. All actions use the same menu dispatch as the egui host.
use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu};
use soundcraft_ui_egui::{SoundApp, menus};
use std::sync::mpsc::{Receiver, channel};
struct Entry {
item: CheckMenuItem,
command: Option<String>,
path: String,
enabled: bool,
checked: bool,
}
pub struct NativeMenu {
menu: Menu,
entries: Vec<Entry>,
events: Receiver<MenuEvent>,
/// When `sync` last walked the entries; see `SYNC_EVERY`.
last_sync: Option<std::time::Instant>,
}
/// `sync` walks every menu leaf (~500), so it runs a few times a second rather than every frame,
/// and right after a menu click.
const SYNC_EVERY: std::time::Duration = std::time::Duration::from_millis(250);
impl NativeMenu {
/// Called on the UI thread after eframe has created NSApplication.
pub fn new(app: &SoundApp, ctx: &egui::Context) -> muda::Result<Self> {
let menu = Menu::new();
let (tx, events) = channel();
let mut native = Self { menu, entries: Vec::new(), events, last_sync: None };
let app_menu = Submenu::new("SoundCraft", true);
native.add_item(app, &app_menu, "About SoundCraft", "", Some("window.about".into()))?;
native.add_item(app, &app_menu, "Session Info", "", Some("window.session_info".into()))?;
native.add_item(app, &app_menu, "Session Audio Health", "", Some("window.audio_health".into()))?;
app_menu.append(&PredefinedMenuItem::separator())?;
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))?;
app_menu.append(&PredefinedMenuItem::show_all(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?;
native.menu.append(&app_menu)?;
let aliases = menus::ui_aliases();
for root in menus::tree() {
let submenu = native.add_branch(app, &root, &aliases)?;
native.menu.append(&submenu)?;
}
native.menu.init_for_nsapp();
let ctx = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
if tx.send(event).is_ok() {
ctx.request_repaint();
}
}));
Ok(native)
}
fn add_branch(&mut self, app: &SoundApp, node: &menus::MenuNode, aliases: &[(&str, &str)]) -> muda::Result<Submenu> {
let submenu = Submenu::new(&node.label, true);
for child in &node.children {
if child.children.is_empty() {
self.add_item(app, &submenu, &child.label, &child.path, menus::command_for_path(&child.path, aliases))?;
} else {
submenu.append(&self.add_branch(app, child, aliases)?)?;
}
}
Ok(submenu)
}
fn add_item(&mut self, app: &SoundApp, parent: &Submenu, label: &str, path: &str, command: Option<String>) -> muda::Result<()> {
let (enabled, checked) = menus::item_state(app, path, command.as_deref());
// egui owns keyboard shortcuts, so a key press is dispatched only once.
let item = CheckMenuItem::new(label, enabled, checked, None);
parent.append(&item)?;
self.entries.push(Entry { item, command, path: path.into(), enabled, checked });
Ok(())
}
pub fn process(&mut self, app: &mut SoundApp) {
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)
&& let Some(command) = &entry.command
&& menus::item_state(app, &entry.path, Some(command)).0
{
if entry.path.is_empty() && command.starts_with("window.") {
// The application menu's About / Session Info open their window (never close it).
let _ = app.run(command, serde_json::json!({"value": true}));
} else {
menus::invoke_menu(app, command, &entry.path);
}
}
}
}
/// Refresh after engine/control changes; avoid touching Cocoa items with unchanged state.
pub fn sync(&mut self, app: &SoundApp) {
let now = std::time::Instant::now();
if self.last_sync.is_some_and(|t| now.duration_since(t) < SYNC_EVERY) {
return;
}
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 enabled != entry.enabled {
entry.item.set_enabled(enabled);
entry.enabled = enabled;
}
// Cocoa toggles check items on click, including ordinary command items.
if checked != entry.checked || entry.item.is_checked() != checked {
entry.item.set_checked(checked);
entry.checked = checked;
}
}
}
}
impl Drop for NativeMenu {
fn drop(&mut self) {
self.menu.remove_for_nsapp();
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
}
}
+176 -11
View File
@@ -2,23 +2,179 @@
"schema": 1,
"repo": "storytold/soundcraft",
"branch": "main",
"head": "3597f895cc49ad795650839c1eeeeac0f88e2aa9",
"head_date": "2026-10-07T18:45:52Z",
"commits": 49,
"head": "caa4872df2aa81a21e401071d2e0d09002658e9e",
"head_date": "2026-10-10T00:50:08Z",
"commits": 98,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 49,
"lines_added": 78099,
"lines_deleted": 1928,
"prs": 5,
"commits": 56,
"lines_added": 79132,
"lines_deleted": 1983,
"binary_added": 23,
"binary_deleted": 0,
"first_commit": "2026-10-06T00:08:04Z",
"last_commit": "2026-10-07T18:45:52Z"
"last_commit": "2026-10-10T00:50:08Z"
},
{
"login": "reoniazx",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 7,
"lines_added": 453,
"lines_deleted": 62,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T06:10:03Z",
"last_commit": "2026-10-09T15:12:12Z"
},
{
"login": "switch87",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 981,
"lines_deleted": 18,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T10:25:49Z",
"last_commit": "2026-10-08T10:27:07Z"
},
{
"login": "h-a-n-a-s-h-i",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 3,
"lines_added": 65,
"lines_deleted": 62,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:13:25Z",
"last_commit": "2026-10-08T14:02:25Z"
},
{
"login": "XusBadia",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 243,
"lines_deleted": 1,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:12:32Z",
"last_commit": "2026-10-08T13:12:32Z"
},
{
"login": "ebbicott",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 5,
"lines_added": 791,
"lines_deleted": 97,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T17:45:32Z",
"last_commit": "2026-10-08T19:40:37Z"
},
{
"login": "MarekSpodBiedry",
"people_entry": false,
"github_name": null,
"prs": 6,
"commits": 7,
"lines_added": 274,
"lines_deleted": 35,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T18:06:42Z",
"last_commit": "2026-10-09T19:25:52Z"
},
{
"login": "Garygunn94",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 7,
"lines_added": 386,
"lines_deleted": 38,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T20:24:47Z",
"last_commit": "2026-10-09T15:05:24Z"
},
{
"login": "vbasky",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 4,
"lines_added": 829,
"lines_deleted": 137,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T00:12:26Z",
"last_commit": "2026-10-09T22:37:04Z"
},
{
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 67,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T00:58:22Z",
"last_commit": "2026-10-09T00:58:22Z"
},
{
"login": "hamada147",
"people_entry": true,
"github_name": "Ahmed Moussa",
"prs": 1,
"commits": 1,
"lines_added": 27,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T08:49:05Z",
"last_commit": "2026-10-09T08:49:05Z"
},
{
"login": "salignatmoandal",
"people_entry": true,
"github_name": "where's Mawen?",
"prs": 2,
"commits": 3,
"lines_added": 119,
"lines_deleted": 6,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T19:03:36Z",
"last_commit": "2026-10-09T19:24:17Z"
},
{
"login": "grovebit",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 198,
"lines_deleted": 5,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T21:45:39Z",
"last_commit": "2026-10-09T21:45:39Z"
}
],
"models": [
@@ -27,9 +183,18 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 47,
"lines_added": 78091,
"lines_deleted": 1927
"commits": 71,
"lines_added": 80906,
"lines_deleted": 2305
},
{
"id": "anthropic/claude-haiku/5.5",
"company": "Anthropic",
"model": "Claude Haiku",
"version": "5.5",
"commits": 5,
"lines_added": 368,
"lines_deleted": 35
}
]
}
+14
View File
@@ -21,3 +21,17 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.hamada147]
real_name = "Ahmed Moussa"
display_name = "Ahmed Moussa"
sync_github_name = true
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.salignatmoandal]
real_name = "Mawen Salignat-Moandal"
sync_github_name = true
+13
View File
@@ -257,6 +257,19 @@ pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
}
}
/// Formats [`encode`] can write, as the extensions that name them.
pub const ENCODE_EXTENSIONS: &str = "wav, aif, aiff, flac";
/// The format [`encode`] writes for a file extension or format name (`wav`, `aiff`, `flac`, case-insensitive), or `None` when it cannot write it.
pub fn encode_format_for(name: &str) -> Option<FileFormat> {
match name.trim_start_matches('.').to_ascii_lowercase().as_str() {
"wav" | "wave" | "bwf" => Some(FileFormat::Wav),
"aif" | "aiff" => Some(FileFormat::Aiff),
"flac" => Some(FileFormat::Flac),
_ => None,
}
}
/// Encode a WAV file in the RF64 layout regardless of size (normally used only above 4 GiB).
pub fn encode_wav_rf64(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true, None)
+13
View File
@@ -0,0 +1,13 @@
use soundcraft_audio_io::{FileFormat, encode_format_for};
#[test]
fn only_formats_the_encoder_writes_are_named() {
assert_eq!(encode_format_for("wav"), Some(FileFormat::Wav));
assert_eq!(encode_format_for("WAV"), Some(FileFormat::Wav));
assert_eq!(encode_format_for(".aif"), Some(FileFormat::Aiff));
assert_eq!(encode_format_for("aiff"), Some(FileFormat::Aiff));
assert_eq!(encode_format_for("flac"), Some(FileFormat::Flac));
for other in ["mp3", "ogg", "m4a", "xyz", ""] {
assert_eq!(encode_format_for(other), None, "{other}");
}
}
+5
View File
@@ -278,6 +278,11 @@ pub fn gain_to_db(g: f32) -> f32 {
(20.0 * a.log10()).max(MIN_DB)
}
/// MIDI note number to frequency in Hz (equal temperament, A4 = note 69 = 440 Hz).
pub fn midi_to_hz(note: f64) -> f64 {
440.0 * 2f64.powf((note - 69.0) / 12.0)
}
#[cfg(test)]
mod trait_default_tests {
use super::*;
+1
View File
@@ -14,6 +14,7 @@ soundcraft-midi.workspace = true
soundcraft-dsp.workspace = true
soundcraft-clap-host.workspace = true
soundcraft-vst3-host.workspace = true
soundcraft-au-host.workspace = true
soundcraft-model.workspace = true
soundcraft-mix.workspace = true
soundcraft-video.workspace = true
+17 -2
View File
@@ -25,9 +25,13 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// A built-in plugin's description, else a hosted CLAP (`clap:<id>`) or VST3 (`vst3:<class id>`) plugin's.
/// A built-in plugin's description, else a hosted CLAP (`clap:<id>`), VST3 (`vst3:<class id>`) or
/// Audio Units (`au:<type>:<subtype>:<manufacturer>`) plugin's.
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
soundcraft_dsp::plugin_info(id).or_else(|| soundcraft_clap_host::plugin_info(id)).or_else(|| soundcraft_vst3_host::plugin_info(id))
soundcraft_dsp::plugin_info(id)
.or_else(|| soundcraft_clap_host::plugin_info(id))
.or_else(|| soundcraft_vst3_host::plugin_info(id))
.or_else(|| soundcraft_au_host::plugin_info(id))
}
#[cfg(test)]
@@ -52,4 +56,15 @@ mod tests {
assert!(super::plugin_info("eq_7band").is_some());
assert!(super::plugin_info("clap:no.such.plugin").is_none());
}
/// Apple's own Audio Units ship with every Mac, so `mix.insert` can be checked against one.
#[cfg(target_os = "macos")]
#[test]
fn audio_units_insert_by_id() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let t = e.session().tracks[0].id.0;
let r = e.execute("mix.insert", &json!({"track": t, "plugin": "au:aufx:bpas:appl"}));
assert_eq!(r.map(|v| v["plugin"].clone()), Ok(json!("au:aufx:bpas:appl")), "Apple AUBandpass");
}
}
+22
View File
@@ -68,6 +68,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("clip.loop", "Loop...", ["Clip"], Some("Cmd+Alt+L"), "{clips?, count?: n, length?: samples}", has_selection, loop_clips),
cmd!("clip.rename", "Rename...", ["Clip"], Some("Cmd+Alt+Shift+R"), "{clip?, name}", has_selection, rename),
cmd!("clip.gain", "Clip Gain", [], None, "{clips?, db | delta_db}", has_selection, gain),
cmd!("clip.gain_nudge_up", "Nudge Clip Gain Up", [], Some("Ctrl+Shift+Up"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, 1.0)),
cmd!("clip.gain_nudge_down", "Nudge Clip Gain Down", [], Some("Ctrl+Shift+Down"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, -1.0)),
cmd!("clip.gain_render", "Render", ["Clip", "Clip Gain"], None, "{clips?}", has_selection, |e, p| {
let ids = clip_ids_param(e, p);
let mut n = 0;
@@ -326,6 +328,11 @@ fn gain(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"clips": ids.len()}))
}
fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result<Value> {
let clips = clip_ids_param(e, p);
gain(e, &json!({"clips": clips, "delta_db": db}))
}
fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session();
@@ -365,4 +372,19 @@ mod tests {
e.execute("clip.remove_warp", &json!({"clips": [cid]})).unwrap();
assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0);
}
#[test]
fn gain_nudges_one_db_at_a_time_up_to_the_ceiling() {
let mut e = crate::demo::demo_engine();
let kick = e.session().track_by_name("Kick").unwrap().clips()[0].id;
let gain = |e: &crate::Engine| e.session().find_clip(kick).unwrap().1.gain_db;
e.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
e.execute("clip.gain_nudge_up", &json!({})).unwrap();
e.execute("clip.gain_nudge_up", &json!({})).unwrap();
e.execute("clip.gain_nudge_down", &json!({})).unwrap();
assert_eq!(gain(&e), 1.0);
e.execute("clip.gain", &json!({"db": 35.5})).unwrap();
e.execute("clip.gain_nudge_up", &json!({})).unwrap();
assert_eq!(gain(&e), 36.0);
}
}
+18
View File
@@ -359,3 +359,21 @@ fn step_marker(e: &mut Engine, dir: i64) -> Result<Value> {
e.transport_requests.push(crate::TransportRequest::Locate(t));
Ok(json!({"at": t}))
}
#[cfg(test)]
mod tests {
use crate::demo::demo_engine;
use serde_json::json;
/// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position.
#[test]
fn tempo_change_at_position_is_applied() {
let mut e = demo_engine();
let sr = e.session().sample_rate;
let at = sr.samples(4.0);
e.execute("event.tempo", &json!({"bpm": 133.5, "at": at})).unwrap();
let s = e.session();
let tick = s.tempo.samples_to_ticks(at, s.sample_rate);
assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9);
}
}
+131 -10
View File
@@ -3,7 +3,7 @@
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::Session;
use soundcraft_model::{Session, TrackKind};
use soundcraft_time::SampleRate;
pub fn specs() -> Vec<CommandSpec> {
@@ -11,8 +11,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "session.new", "New...", ["File"], Some("Cmd+N"), "{name?: 'Untitled', sample_rate?: 48000, bit_depth?: 24, template?: blank|demo}", always, new_session),
cmd!(noundo "session.open", "Open Session...", ["File"], Some("Cmd+O"), "{path}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.open", "`path` required"))?.to_string();
crate::io::open_session(e, &path)?;
Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len()}))
let missing = crate::io::open_session(e, &path)?;
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| {
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Audio...",
["File", "Import"],
Some("Cmd+Shift+I"),
"{path | paths: [..], track?: target track, at?: position, new_tracks?: true}",
"{path | paths: [..], track?: target track, at?: position, new_tracks?: true (default; false uses `track` or the selected audio track)}",
always,
import_audio
),
@@ -208,7 +208,7 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
return Err(bad("file.import_audio", "`path` or `paths` required"));
}
let at = position_param(e, "file.import_audio", p, "at")?.unwrap_or(0).max(0);
let target = track_param(e, "file.import_audio", p, "track")?;
let target = import_audio_target(e, p)?;
let mut out = Vec::new();
for path in &paths {
let bytes = std::fs::read(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
@@ -219,6 +219,21 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"imported": out}))
}
/// Destination for `file.import_audio`: an explicit `track` wins; otherwise `new_tracks`
/// (default true) creates a new track per file, and `new_tracks: false` uses the selected
/// audio track or errors when none is selected.
fn import_audio_target(e: &Engine, p: &Value) -> Result<Option<TrackId>> {
if let Some(t) = track_param(e, "file.import_audio", p, "track")? {
return Ok(Some(t));
}
if bool_or(p, "new_tracks", true) {
return Ok(None);
}
let s = e.session();
let selected = s.edit.selected_tracks.iter().find_map(|id| s.track(*id).filter(|t| t.kind == TrackKind::Audio).map(|t| t.id));
selected.map(Some).ok_or_else(|| bad("file.import_audio", "`new_tracks: false` needs `track` or a selected audio track"))
}
/// `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) {
@@ -233,11 +248,10 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
let r = range_param(e, "file.bounce_mix", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
let format = match str_param(p, "format").unwrap_or(ext.as_str()) {
"aif" | "aiff" => soundcraft_audio_io::FileFormat::Aiff,
"flac" => soundcraft_audio_io::FileFormat::Flac,
_ => soundcraft_audio_io::FileFormat::Wav,
};
let format_name = str_param(p, "format").unwrap_or(ext.as_str());
let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| {
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS))
})?;
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
@@ -270,3 +284,110 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
Ok(json!({"path": path, "parts": parts.len(), "measures": measures}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Engine;
use soundcraft_audio_io::{AudioBuffer, EncodeOptions};
use soundcraft_model::ChannelFormat;
fn tone_wav(dir: &std::path::Path, name: &str) -> String {
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.1).sin() * 0.2).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn import_audio_honours_new_tracks() {
let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
// Default / new_tracks:true: create a new track even when one is selected.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
let target = e.session().tracks[0].id;
e.session_mut().edit.selected_tracks = vec![target];
e.execute("file.import_audio", &json!({"path": wav, "new_tracks": true})).unwrap();
assert_eq!(e.session().tracks.len(), 2);
assert_eq!(e.session().track(target).unwrap().clips().len(), 0);
assert_eq!(e.session().tracks[1].clips().len(), 1);
// new_tracks:false with a selected audio track: import onto it.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
let target = e.session().tracks[0].id;
e.session_mut().edit.selected_tracks = vec![target];
e.execute("file.import_audio", &json!({"path": wav, "new_tracks": false})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().track(target).unwrap().clips().len(), 1);
// new_tracks:false with no selection and no track: clear error.
let mut e = Engine::default();
let err = e.execute("file.import_audio", &json!({"path": wav, "new_tracks": false})).unwrap_err();
assert!(err.to_string().contains("new_tracks: false"), "{err}");
// Explicit track wins over new_tracks:true.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
e.execute("file.import_audio", &json!({"path": wav, "track": "Target", "new_tracks": true})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].clips().len(), 1);
// Omitted new_tracks still creates a track on a blank session.
let mut e = Engine::default();
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].kind, TrackKind::Audio);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Mono);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn bounce_refuses_formats_it_cannot_write() {
let mut e = Engine::default();
let dir = std::env::temp_dir().join(format!("sc-bounce-ext-{}", std::process::id()));
assert!(std::fs::create_dir_all(&dir).is_ok());
for name in ["mix.mp3", "mix.ogg", "mix.xyz"] {
let path = dir.join(name);
let err = e.execute("file.bounce_mix", &json!({"path": path.to_string_lossy(), "start": 0, "end": 480})).map(|_| ()).unwrap_err();
assert!(err.to_string().contains("supported: wav"), "{name}: {err}");
assert!(!path.exists(), "{name} must not be written");
}
let ok = dir.join("mix.flac");
assert!(e.execute("file.bounce_mix", &json!({"path": ok.to_string_lossy(), "start": 0, "end": 480})).is_ok());
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();
if p.is_dir() {
remove_media(&p);
} else if p.extension().is_some_and(|x| x != "scraft") {
let _ = std::fs::remove_file(&p);
}
}
}
#[test]
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 path = dir.join("Gone.scraft").to_string_lossy().into_owned();
e.execute("session.save_as", &json!({"path": path})).unwrap();
let mut ok = Engine::default();
assert!(ok.execute("session.open", &json!({"path": path})).unwrap()["missing"].as_array().is_some_and(Vec::is_empty));
remove_media(&dir);
let mut r = Engine::default();
let res = r.execute("session.open", &json!({"path": path})).unwrap();
assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Media availability checks without decoding files or changing the session.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::ClipContent;
use std::collections::BTreeMap;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "session.audio_health", "Session Audio Health", [], None, "{}; checks all audio clips, including inactive playlists; no filesystem or signal analysis", always, report),
]
}
fn report(e: &mut Engine, _: &Value) -> Result<Value> {
let s = e.session();
let sources: BTreeMap<_, _> = s.sources.iter().map(|source| (source.id, source)).collect();
let mut issues = Vec::new();
if s.sample_rate.hz() == 0 {
issues.push(json!({"code": "invalid_sample_rate", "track": "Session", "message": "Session sample rate is zero. Set a valid rate in Session Setup before playback.", "active": true}));
}
let mut clips = 0usize;
let mut active_end = 0i64;
for track in &s.tracks {
for (index, playlist) in track.playlists.iter().enumerate() {
let active = index == track.active_playlist;
for clip in &playlist.clips {
let ClipContent::Audio { source, offset } = clip.content else { continue };
clips += 1;
if active {
active_end = active_end.max(clip.end());
}
let metadata = sources.get(&source);
let audio = s.pool.get(source);
let mut add = |code: &str, message: &str| {
issues.push(json!({"code": code, "message": message, "track": track.name, "track_id": track.id.0,
"playlist": playlist.name, "active": active, "clip": clip.name, "clip_id": clip.id.0,
"source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
};
if metadata.is_none() {
add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
}
let Some(audio) = audio else {
add(
"unavailable_audio",
"Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.",
);
continue;
};
if audio.buffer.sample_rate != s.sample_rate.hz() {
add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
}
if audio.buffer.channels.is_empty() || audio.frames() == 0 {
add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
continue;
}
if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
add(
"uneven_channels",
"Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.",
);
}
if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
}
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
// Match the mixer's last requested source sample, including elastic playback.
let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
if offset < 0 || last >= audio.frames() as f64 {
add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
}
}
}
}
Ok(json!({"audio_clips": clips, "active_audio_end_samples": active_end,
"active_audio_end_seconds": (s.sample_rate.hz() != 0).then(|| active_end as f64 / f64::from(s.sample_rate.hz())),
"issue_count": issues.len(), "healthy": issues.is_empty(), "issues": issues,
"scope": "All playlists; loaded audio availability and clip bounds only. Does not check files on disk, signal levels, plugins or routing."}))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{Clip, ClipId, SourceAudio, SourceId};
use std::sync::Arc;
fn check(e: &mut Engine) -> Value {
e.execute("session.audio_health", &json!({})).unwrap()
}
#[test]
fn empty_and_demo_are_healthy_and_query_is_read_only() {
for mut e in [Engine::default(), crate::demo::demo_engine()] {
let before = e.session_arc();
let revision = e.revision;
let dirty = e.is_dirty();
assert_eq!(check(&mut e)["healthy"], true);
assert_eq!(*before, *e.session());
assert_eq!(e.revision, revision);
assert_eq!(e.is_dirty(), dirty);
assert!(e.journal.is_empty());
assert!(!e.can_undo());
}
}
#[test]
fn reports_missing_media_in_inactive_takes() {
let mut e = crate::demo::demo_engine();
let t = e.session_mut().tracks.first_mut().unwrap();
let mut p = soundcraft_model::Playlist::new("Alternate take");
p.clips.push(Clip::audio(ClipId(999), "Lost take", SourceId(999), 0, 0, 10));
t.playlists.push(p);
let r = check(&mut e);
assert_eq!(r["issue_count"], 2);
assert_eq!(r["issues"][0]["code"], "missing_source");
assert_eq!(r["issues"][1]["code"], "unavailable_audio");
assert_eq!(r["issues"][0]["active"], false);
}
#[test]
fn checks_loaded_bounds_and_stretch_not_stale_metadata() {
let mut e = crate::demo::demo_engine();
let id = e.session().sources.first().unwrap().id;
let sr = e.session().sample_rate.hz();
e.session_mut().pool.insert(id, Arc::new(SourceAudio::new(AudioBuffer::new(sr, 1, 10))));
let t = e.session_mut().tracks.first_mut().unwrap();
t.playlists.clear();
let mut p = soundcraft_model::Playlist::new("Bounds");
let mut c = Clip::audio(ClipId(999), "Stretch", id, 0, 0, 20);
c.stretch = 2.0;
p.clips.push(c);
t.playlists.push(p);
t.active_playlist = 0;
assert_eq!(check(&mut e)["healthy"], true);
e.session_mut().tracks.first_mut().unwrap().playlists[0].clips[0].length = 21;
assert_eq!(check(&mut e)["issues"][0]["code"], "source_bounds");
let clip = &mut e.session_mut().tracks.first_mut().unwrap().playlists[0].clips[0];
clip.length = i64::MAX;
clip.stretch = f64::NAN;
clip.content = ClipContent::Audio { source: id, offset: i64::MAX };
let r = check(&mut e);
assert_eq!(r["issues"][0]["code"], "invalid_clip");
assert_eq!(r["issues"][1]["code"], "source_bounds");
}
#[test]
fn malformed_and_empty_audio_are_reported_without_panicking() {
let mut e = crate::demo::demo_engine();
let id = e.session().sources.first().unwrap().id;
e.session_mut().pool.insert(id, Arc::new(SourceAudio::new(AudioBuffer::new(1, 0, 0))));
let r = check(&mut e);
assert!(r["issues"].as_array().unwrap().iter().any(|i| i["code"] == "empty_audio"));
assert!(r["issues"].as_array().unwrap().iter().any(|i| i["code"] == "sample_rate_mismatch"));
}
#[test]
fn zero_rate_has_unknown_duration_and_uneven_channels_use_shortest() {
let mut e = crate::demo::demo_engine();
let id = e.session().sources.first().unwrap().id;
let sr = e.session().sample_rate.hz();
let audio = AudioBuffer { sample_rate: sr, channels: vec![vec![0.0; 10], vec![0.0; 5]] };
e.session_mut().pool.insert(id, Arc::new(SourceAudio::new(audio)));
let r = check(&mut e);
assert!(r["issues"].as_array().unwrap().iter().any(|i| i["code"] == "uneven_channels"));
assert!(r["issues"].as_array().unwrap().iter().any(|i| i["code"] == "source_bounds"));
e.session_mut().sample_rate = serde_json::from_value(json!(0)).unwrap();
let r = check(&mut e);
assert!(r["active_audio_end_seconds"].is_null());
assert_eq!(r["issues"][0]["code"], "invalid_sample_rate");
}
}
+83
View File
@@ -0,0 +1,83 @@
//! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI
//! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`;
//! the instrument is otherwise only set when the track is created (`track.new`).
use super::*;
use crate::cmd;
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
)]
}
fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.instrument", "`plugin` required"))?;
let info = super::clap::plugin_info(plugin).ok_or_else(|| bad("mix.instrument", format!("unknown plugin `{plugin}`")))?;
if !info.is_instrument {
return Err(bad("mix.instrument", format!("`{plugin}` is not an instrument")));
}
let t = tracks_required(e, "mix.instrument", p)?.first().copied().ok_or_else(|| bad("mix.instrument", "no track"))?;
if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) {
return Err(bad("mix.instrument", "only instrument tracks have an instrument"));
}
let mut ins = Insert::new(info.id);
for pi in info.params {
ins.params.insert(pi.id.to_string(), pi.default);
}
if let Some(tr) = e.session_mut().track_mut(t) {
tr.instrument = Some(ins);
}
Ok(json!({"track": t, "plugin": info.id}))
}
#[cfg(test)]
mod tests {
use crate::Engine;
use serde_json::json;
use soundcraft_model::TrackId;
fn track(e: &mut Engine, kind: &str) -> TrackId {
e.execute("track.new", &json!({"kind": kind})).unwrap();
e.session().tracks.last().unwrap().id
}
fn instrument_of(e: &Engine, t: TrackId) -> Option<String> {
e.session().track(t).and_then(|tr| tr.instrument.as_ref()).map(|i| i.plugin.clone())
}
#[test]
fn sets_an_instrument_tracks_instrument_with_defaults_and_undoes() {
let mut e = Engine::default();
let t = track(&mut e, "instrument");
assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth"));
let r = e.execute("mix.instrument", &json!({"track": t.0, "plugin": "drum_synth"})).unwrap();
assert_eq!(r["plugin"], "drum_synth");
assert_eq!(instrument_of(&e, t).as_deref(), Some("drum_synth"));
let params = e.session().track(t).and_then(|tr| tr.instrument.as_ref()).map(|i| i.params.len());
assert_eq!(params, soundcraft_dsp::plugin_info("drum_synth").map(|p| p.params.len()), "every parameter at its default");
e.execute("edit.undo", &json!({})).unwrap();
assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth"));
}
#[test]
fn refuses_effects_unknown_plugins_and_other_track_kinds() {
let mut e = Engine::default();
let inst = track(&mut e, "instrument");
let audio = track(&mut e, "audio");
assert!(e.execute("mix.instrument", &json!({"track": inst.0, "plugin": "eq_7band"})).is_err(), "an effect");
assert!(e.execute("mix.instrument", &json!({"track": inst.0, "plugin": "vst3:00000000000000000000000000000000"})).is_err());
assert!(e.execute("mix.instrument", &json!({"track": inst.0})).is_err(), "no plugin");
assert!(e.execute("mix.instrument", &json!({"track": audio.0, "plugin": "drum_synth"})).is_err(), "an audio track");
assert_eq!(instrument_of(&e, inst).as_deref(), Some("subtractive_synth"));
assert_eq!(instrument_of(&e, audio), None);
}
}
+4
View File
@@ -16,6 +16,8 @@ mod edit_more;
mod event;
mod event_more;
mod file;
mod health;
mod instrument;
mod midi;
mod mix;
mod more_util;
@@ -107,6 +109,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(options::specs());
v.extend(transport::specs());
v.extend(query::specs());
v.extend(health::specs());
v.extend(audiosuite::specs());
v.extend(audio_midi::specs());
v.extend(midi::specs());
@@ -119,6 +122,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(vst3::specs());
v.extend(surround::specs());
v.extend(plugin_state::specs());
v.extend(instrument::specs());
v.extend(video::specs());
v
})
+2 -6
View File
@@ -110,10 +110,6 @@ fn drums(sr: f64, frames: usize) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
(k, s, h)
}
fn midi_hz(n: f64) -> f64 {
440.0 * 2f64.powf((n - 69.0) / 12.0)
}
/// Chord roots per bar (A minor, F, C, G progression), as MIDI notes.
const PROG: [[u8; 4]; 4] = [[57, 60, 64, 69], [53, 57, 60, 65], [48, 52, 55, 60], [55, 59, 62, 67]];
@@ -127,7 +123,7 @@ fn bass(sr: f64, frames: usize) -> Vec<f32> {
for (eighth, semi, beats) in pattern {
let at = bar * beat * 4 + eighth * beat / 2;
let len = (beats * beat as f64) as usize;
let f = midi_hz(root + semi);
let f = soundcraft_dsp::midi_to_hz(root + semi);
let mut ph = 0.0f64;
for i in 0..len {
let Some(o) = out.get_mut(at + i) else { break };
@@ -152,7 +148,7 @@ fn pad(sr: f64, frames: usize) -> (Vec<f32>, Vec<f32>) {
let chord = PROG[bar % 4];
let at = bar * bar_len;
for (vi, n) in chord.iter().enumerate() {
let f = midi_hz(f64::from(*n) + 12.0);
let f = soundcraft_dsp::midi_to_hz(f64::from(*n) + 12.0);
for (side, buf) in [(-1.0f64, &mut l), (1.0, &mut r)] {
let det = 1.0 + side * 0.0035 * (vi as f64 + 1.0);
let mut ph = vi as f64 * 0.17;
+26 -1
View File
@@ -80,7 +80,10 @@ pub fn session(e: &Engine, full: bool) -> Value {
"busses": s.busses.iter().map(|b| json!({"id": b.id, "name": b.name, "format": b.format.label()})).collect::<Vec<_>>(),
"markers": s.markers.iter().map(|m| json!({"id": m.id, "number": m.number, "name": m.name, "kind": m.kind, "start": m.start, "end": m.end})).collect::<Vec<_>>(),
"groups": s.groups.iter().map(|g| json!({"id": g.id, "name": g.name, "letter": g.letter, "members": g.members, "active": g.active})).collect::<Vec<_>>(),
"sources": s.sources.iter().map(|x| json!({"id": x.id, "name": x.name, "channels": x.channels, "frames": x.frames, "path": x.path})).collect::<Vec<_>>(),
"sources": s.sources.iter().map(|x| json!({
"id": x.id, "name": x.name, "channels": x.channels, "frames": x.frames, "path": x.path,
"loaded": s.pool.contains(x.id),
})).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(),
"transport": e.transport,
@@ -146,3 +149,25 @@ pub fn session_text(e: &Engine) -> String {
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn sources_report_whether_audio_is_loaded() {
let mut e = crate::demo::demo_engine();
let before = session(&e, false);
let sources = before["sources"].as_array().expect("sources");
assert!(!sources.is_empty());
assert!(sources.iter().all(|s| s["loaded"] == true), "{sources:?}");
let id = e.session().sources[0].id;
assert!(e.session_mut().pool.remove(id).is_some());
let after = session(&e, false);
let entry = after["sources"].as_array().unwrap().iter().find(|s| s["id"] == json!(id)).expect("source still listed");
assert_eq!(entry["loaded"], false, "{entry}");
assert_eq!(entry["path"], before["sources"][0]["path"], "metadata stays even when unloaded");
}
}
+7
View File
@@ -23,6 +23,13 @@ use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use soundcraft_model as model;
/// Shuts the third-party plugin hosts down, running the module exit of every plugin binary they
/// loaded. Call it at the end of `main`, once every plugin instance (mixer, player) is gone: a
/// plugin that has been used can crash when its module exit only runs from an exit handler.
pub fn shutdown_plugin_hosts() {
soundcraft_vst3_host::shutdown();
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
+222 -1
View File
@@ -299,6 +299,9 @@ pub struct MixEngine {
pub monitor_only: bool,
/// Recording: record-armed tracks hear their input (auto input).
pub recording: bool,
/// Mix the metronome (Options > Click) into the output. Playback turns this on; offline renders
/// (bounces, stems, exports) leave it off so the click is never printed into a file.
pub metronome: bool,
/// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`.
external: bool,
/// Instances handed over by the host, waiting for `sync` to place them.
@@ -363,6 +366,7 @@ impl MixEngine {
input: Vec::new(),
monitor_only: false,
recording: false,
metronome: false,
external: false,
adopt: HashMap::new(),
retired: Vec::new(),
@@ -655,6 +659,13 @@ impl MixEngine {
self.meters.entry(t.id).or_default().measure(&self.main, frames, 0.0);
}
}
// Metronome: summed after the master fader, so it reaches the speakers without passing
// through any track, send or insert. Only the playback engine sets `metronome`.
// Not while monitoring only (transport stopped): the position does not advance there, so the
// start of the same beat would repeat every block as a buzz.
if self.metronome && !self.monitor_only && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) {
click::render(s, pos, frames, &mut self.main);
}
// Output: min(out.len(), main channels) channels; any further output channels are silent.
for (c, o) in out.iter_mut().enumerate() {
let src = self.main.get(c);
@@ -1000,12 +1011,47 @@ fn structure_fingerprint(s: &Session) -> usize {
h.finish() as usize
}
std::thread_local! {
static AUDIO_THREAD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Mark the calling thread as the realtime audio thread (`soundcraft_playback`'s cpal callbacks
/// do, every call: one thread-local store). Strips [`MixEngine::render`] hands to worker threads
/// count as the audio thread while they render for it. Code that might run there asks
/// [`on_audio_thread`] before doing anything that blocks; the desktop app's logger does, so a
/// `log::` record from the audio thread never takes a lock or touches a file.
pub fn mark_audio_thread() {
AUDIO_THREAD.set(true);
}
/// True on the thread [`mark_audio_thread`] marked, and on a worker while it renders strips for
/// it. Lock-free and allocation-free.
pub fn on_audio_thread() -> bool {
AUDIO_THREAD.get()
}
/// Run `f` on this thread as (or as not) the audio thread, then restore the thread's own mark
/// (also when `f` unwinds): a pool worker renders for realtime playback and offline bounces alike.
#[cfg(not(target_arch = "wasm32"))]
fn as_audio_thread(audio: bool, f: impl FnOnce()) {
struct Restore(bool);
impl Drop for Restore {
fn drop(&mut self) {
AUDIO_THREAD.set(self.0);
}
}
let _restore = Restore(AUDIO_THREAD.replace(audio));
f();
}
/// Process strips, in parallel on native targets when there are enough of them.
#[cfg(not(target_arch = "wasm32"))]
fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {
use rayon::prelude::*;
if work.len() >= 4 {
work.par_iter_mut().for_each(f);
// The workers render on behalf of this thread: on the audio thread, they are it too.
let audio = on_audio_thread();
work.par_iter_mut().for_each(|w| as_audio_thread(audio, || f(w)));
} else {
work.iter_mut().for_each(f);
}
@@ -1075,6 +1121,67 @@ fn receives_solo(s: &Session, t: &Track) -> bool {
.any(|o| o.mixer.solo && (o.mixer.output == Route::Bus(*b) || o.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(*b))))
}
/// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of
/// each bar. Settings come from `setup.click_countoff` (`click.*` values).
mod click {
use super::*;
/// Blip length in seconds.
const LEN_S: f64 = 0.03;
pub fn render(s: &Session, pos: Samples, frames: usize, main: &mut [Vec<f32>]) {
let sr = s.sample_rate;
let rate = sr.as_f64();
let len = (LEN_S * rate) as i64;
let end = pos.saturating_add(frames as i64);
let gain = f64::from(db_to_gain(s.edit.value("click.volume_db", -6.0).clamp(-60.0, 12.0) as f32));
let accent = (note_hz(s.edit.value("click.accent_note", 84.0)), s.edit.value("click.accent_velocity", 127.0));
let normal = (note_hz(s.edit.value("click.normal_note", 72.0)), s.edit.value("click.normal_velocity", 100.0));
// Start one blip length early so a click that began in an earlier block still rings into this one.
let t0 = s.tempo.samples_to_ticks(pos.saturating_sub(len), sr).max(0).saturating_sub(1);
let mut tick = t0.saturating_sub(s.tempo.bar_beat_at_tick(t0).tick);
// Blocks hold a few beats at most; the guard only protects against a malformed tempo map.
for _ in 0..4096 {
let at = s.tempo.tick_to_samples(tick, sr);
if at >= end {
break;
}
if at.saturating_add(len) > pos {
let (hz, vel) = if s.tempo.bar_beat_at_tick(tick).beat == 1 { accent } else { normal };
let amp = gain * (vel.clamp(1.0, 127.0) / 127.0);
blip(main, at - pos, frames, hz, amp as f32, rate);
}
tick = tick.saturating_add(s.tempo.meter_at_tick(tick).ticks_per_beat());
}
}
/// Adds one decaying sine blip whose first sample sits `rel` frames into the block (negative
/// when it began before the block) to every channel.
fn blip(main: &mut [Vec<f32>], rel: i64, frames: usize, hz: f64, amp: f32, rate: f64) {
let len = (LEN_S * rate) as usize;
let w = std::f64::consts::TAU * hz / rate;
let first = usize::try_from(-rel).unwrap_or(0);
for i in first..len {
let Ok(at) = usize::try_from(rel + i as i64) else { continue };
if at >= frames {
break;
}
let env = (-6.0 * i as f64 / len as f64).exp() as f32;
let v = ((w * i as f64).sin() as f32) * env * amp;
for ch in main.iter_mut() {
if let Some(d) = ch.get_mut(at) {
*d += v;
}
}
}
}
/// Click pitch for a MIDI note setting (clamped to the MIDI range).
fn note_hz(note: f64) -> f64 {
soundcraft_dsp::midi_to_hz(note.clamp(0.0, 127.0))
}
}
fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut [Vec<f32>], _scratch: &mut [f32]) {
let ClipContent::Audio { source, offset } = clip.content else { return };
let block = Range { start: pos, end: pos.saturating_add(frames as i64) };
@@ -1642,6 +1749,50 @@ fn create_plugin(id: &str) -> Option<Box<dyn Plugin>> {
.or_else(|| soundcraft_au_host::create(id))
}
#[cfg(test)]
mod audio_thread_tests {
use super::*;
#[test]
fn only_a_marked_thread_is_the_audio_thread() {
assert!(!on_audio_thread());
let marked = std::thread::spawn(|| {
let before = on_audio_thread();
mark_audio_thread();
(before, on_audio_thread())
})
.join()
.unwrap();
assert_eq!(marked, (false, true));
assert!(!on_audio_thread(), "marking another thread leaves this one alone");
}
/// What `on_audio_thread` says inside each strip job, for strips run from a thread that is
/// (or is not) the audio thread.
fn seen_by_strips(audio: bool) -> Vec<bool> {
std::thread::spawn(move || {
if audio {
mark_audio_thread();
}
let mut work = vec![None; 64];
run_strips(&mut work, |w| {
// Long enough that the pool's workers take part.
std::thread::sleep(std::time::Duration::from_millis(1));
*w = Some(on_audio_thread());
});
work.into_iter().map(|w| w.unwrap()).collect()
})
.join()
.unwrap()
}
#[test]
fn strips_rendered_for_the_audio_thread_count_as_the_audio_thread_and_only_then() {
assert!(seen_by_strips(true).iter().all(|&rt| rt), "a worker rendering for the audio thread is on it");
assert!(seen_by_strips(false).iter().all(|&rt| !rt), "the workers don't stay marked afterwards");
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1934,6 +2085,76 @@ mod tests {
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
}
#[test]
fn stopped_monitoring_does_not_click() {
let mut s = Session::default();
s.edit.click = true;
let mut eng = MixEngine::new(48_000.0, 512);
eng.metronome = true;
eng.monitor_only = true;
let mut out = vec![vec![0.0f32; 512]; 2];
for _ in 0..4 {
eng.render(&s, 0, 512, &mut out);
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
}
}
/// Renders `len` frames from zero the way playback does (metronome on), in blocks of `block`.
fn render_playback(s: &Session, len: usize, block: usize, recording: bool) -> Vec<Vec<f32>> {
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
eng.metronome = true;
eng.recording = recording;
let mut out = vec![vec![0.0f32; len]; 2];
let mut tmp = vec![vec![0.0f32; block]; 2];
let mut done = 0usize;
while done < len {
let n = (len - done).min(block);
eng.render(s, done as i64, n, &mut tmp);
for (o, t) in out.iter_mut().zip(tmp.iter()) {
if let (Some(d), Some(src)) = (o.get_mut(done..done + n), t.get(..n)) {
d.copy_from_slice(src);
}
}
done += n;
}
out
}
#[test]
fn click_sounds_on_every_beat_only_in_playback() {
let mut s = Session::default();
let beat = s.sample_rate.as_f64() as usize * 60 / 120; // 120 BPM quarter notes
let len = beat * 8;
s.edit.click = true;
// Offline renders (bounces, stems) never print the metronome.
let offline = render_range(&s, Range::new(0, len as i64), 512);
assert!(offline.iter().all(|c| c.iter().all(|x| *x == 0.0)));
let out = render_playback(&s, len, 512, false);
for b in 0..8usize {
let at = b * beat;
let on: f32 = out[0][at..at + 200].iter().map(|x| x.abs()).fold(0.0, f32::max);
let off: f32 = out[0][at + beat / 2..at + beat / 2 + 200].iter().map(|x| x.abs()).fold(0.0, f32::max);
assert!(on > 0.1, "beat {b} should click, peak {on}");
assert_eq!(off, 0.0, "between beats must stay silent");
assert_eq!(out[0][at], out[1][at], "both channels get the click");
}
// Blocks that start mid-beat render the same clicks as a single pass.
let split = render_playback(&s, len, 97, false);
assert_eq!(out, split);
}
#[test]
fn click_only_during_record_follows_recording() {
let mut s = Session::default();
s.edit.click = true;
s.edit.set_flag("click.only_during_record", true);
let idle = render_playback(&s, 48_000, 512, false);
assert!(idle.iter().all(|c| c.iter().all(|x| *x == 0.0)));
let rec = render_playback(&s, 48_000, 512, true);
assert!(rec.iter().any(|c| c.iter().any(|x| x.abs() > 0.1)));
}
/// A session with one track of `fmt` playing a distinct DC level per channel (0.1, 0.2, …).
fn multichannel_session(fmt: ChannelFormat, main: ChannelFormat, frames: usize) -> (Session, TrackId) {
let mut s = Session::default();
+16 -3
View File
@@ -35,6 +35,10 @@ use std::sync::{Arc, Mutex, PoisonError};
const BLOCK: usize = 512;
/// The realtime audio thread marker lives in the mix engine, which carries it into the strips it
/// renders on worker threads; the cpal callbacks here set it.
pub use soundcraft_mix::{mark_audio_thread, on_audio_thread};
/// Meter values for the UI.
#[derive(Debug, Clone, Default)]
pub struct MeterSnapshot {
@@ -99,6 +103,7 @@ struct AudioState {
fn new_engine(sr: f32) -> MixEngine {
let mut m = MixEngine::new(sr, BLOCK);
m.set_external_instances(true);
m.metronome = true;
m
}
@@ -222,7 +227,7 @@ impl AudioState {
if self.mix.input.len() != ch {
self.mix.input = vec![vec![0.0; BLOCK]; ch];
}
ring.pop_into(&mut self.mix.input, n);
ring.pop_into(&mut self.mix.input, n, self.session.sample_rate.hz());
}
self.mix.monitor_only = !self.playing;
self.mix.recording = self.recording;
@@ -390,11 +395,19 @@ impl Player {
let rate = config.sample_rate.0;
let channels = usize::from(config.channels);
let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx);
let err_fn = |e: cpal::StreamError| log::warn!("audio stream error: {e}");
// cpal may call this on the audio thread (on ALSA in a loop while the device
// stays broken): marked, so the logger hands the record off instead of writing it.
let err_fn = |e: cpal::StreamError| {
mark_audio_thread();
log::warn!("audio stream error: {e}");
};
use cpal::traits::{DeviceTrait, StreamTrait};
match device.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| state.fill(data, channels),
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
mark_audio_thread();
state.fill(data, channels);
},
err_fn,
None,
) {
+110 -19
View File
@@ -50,13 +50,14 @@ impl Recorder {
let config = cfg.config();
let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) });
let sh = Arc::clone(&shared);
let monitor = InputRing::new(usize::from(config.channels));
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 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();
@@ -72,7 +73,11 @@ impl Recorder {
backlog.extend_from_slice(data);
}
},
|e| log::warn!("input stream error: {e}"),
|e| {
// Possibly the audio thread (see `crate::mark_audio_thread`).
crate::mark_audio_thread();
log::warn!("input stream error: {e}");
},
None,
)
.map_err(|e| e.to_string())?;
@@ -140,47 +145,133 @@ mod tests {
}
}
/// Ring state behind the lock: interleaved input frames at the input device's rate, plus the
/// fractional read position (in input frames) of the next output sample.
struct RingState {
data: std::collections::VecDeque<f32>,
phase: f64,
}
/// A small interleaved FIFO from the input callback to the output callback, for input
/// monitoring. Both sides only `try_lock`; it holds at most ~0.5 s and drops the oldest frames.
#[derive(Default)]
/// The input device may run at a different rate than the session, so the reader converts
/// rates with linear interpolation (cheap, allocation-free, fine for monitoring).
pub struct InputRing {
data: Mutex<std::collections::VecDeque<f32>>,
state: Mutex<RingState>,
pub channels: std::sync::atomic::AtomicUsize,
/// Input device sample rate in Hz (0 when unknown, which reads as "same as the session").
rate: u32,
/// Most samples kept: half a second of input, or 24 000 frames when the rate is unknown.
cap: usize,
}
impl InputRing {
pub fn new(channels: usize) -> Arc<InputRing> {
pub fn new(channels: usize, rate: u32) -> Arc<InputRing> {
let ch = channels.max(1);
let frames = if rate == 0 { 24_000 } else { rate as usize / 2 };
Arc::new(InputRing {
data: Mutex::new(std::collections::VecDeque::with_capacity(48_000 * channels.max(1))),
channels: std::sync::atomic::AtomicUsize::new(channels.max(1)),
state: Mutex::new(RingState { data: std::collections::VecDeque::with_capacity(rate.max(48_000) as usize * ch), phase: 0.0 }),
channels: std::sync::atomic::AtomicUsize::new(ch),
rate,
cap: frames * ch,
})
}
pub fn push(&self, samples: &[f32]) {
if let Ok(mut d) = self.data.try_lock() {
let cap = 24_000 * self.channels.load(Ordering::Relaxed).max(1);
d.extend(samples.iter().copied());
while d.len() > cap {
d.pop_front();
if let Ok(mut s) = self.state.try_lock() {
s.data.extend(samples.iter().copied());
while s.data.len() > self.cap {
s.data.pop_front();
}
}
}
/// Fill `out` (planar) with up to `frames` frames; missing frames are silence.
pub fn pop_into(&self, out: &mut [Vec<f32>], frames: usize) {
/// Fill `out` (planar, at `session_rate`) with up to `frames` frames, converting from the
/// input rate. Missing frames are silence.
pub fn pop_into(&self, out: &mut [Vec<f32>], frames: usize, session_rate: u32) {
let ch = self.channels.load(Ordering::Relaxed).max(1);
for c in out.iter_mut() {
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
}
let Ok(mut d) = self.data.try_lock() else { return };
let avail = (d.len() / ch).min(frames);
for f in 0..avail {
let Ok(mut s) = self.state.try_lock() else { return };
// Input frames consumed per output frame.
let step = if self.rate == 0 || session_rate == 0 { 1.0 } else { f64::from(self.rate) / f64::from(session_rate) };
for f in 0..frames {
// Drop input frames we have moved past; keep the one at `phase` and the next one.
while s.phase >= 1.0 && s.data.len() >= 2 * ch {
s.data.drain(..ch);
s.phase -= 1.0;
}
if s.phase >= 1.0 || s.data.len() < 2 * ch {
break;
}
let fr = s.phase as f32;
for c in 0..ch {
let v = d.pop_front().unwrap_or(0.0);
let a = s.data.get(c).copied().unwrap_or(0.0);
let b = s.data.get(ch + c).copied().unwrap_or(a);
if let Some(x) = out.get_mut(c).and_then(|o| o.get_mut(f)) {
*x = v;
*x = a + (b - a) * fr;
}
}
s.phase += step;
}
}
}
#[cfg(test)]
mod ring_tests {
use super::InputRing;
/// A 1 kHz sine recorded at 16 kHz must come out as a 1 kHz sine at 48 kHz, across calls.
#[test]
fn monitor_converts_input_rate_to_session_rate() {
let ring = InputRing::new(1, 16_000);
let input: Vec<f32> = (0..4_000).map(|i| (2.0 * std::f64::consts::PI * 1_000.0 * f64::from(i) / 16_000.0).sin() as f32).collect();
ring.push(&input);
let mut out = vec![vec![0.0f32; 48]];
let mut k = 0usize;
for _ in 0..10 {
ring.pop_into(&mut out, 48, 48_000);
for v in &out[0] {
let want = (2.0 * std::f64::consts::PI * 1_000.0 * k as f64 / 48_000.0).sin() as f32;
assert!((v - want).abs() < 0.05, "sample {k}: got {v}, want {want}");
k += 1;
}
}
}
/// Input faster than the session (96 kHz into 48 kHz): every other frame is read, in order.
#[test]
fn monitor_decimates_when_input_is_faster() {
let ring = InputRing::new(1, 96_000);
let input: Vec<f32> = (0..1_000).map(|i| i as f32).collect();
ring.push(&input);
let mut out = vec![vec![0.0f32; 100]];
ring.pop_into(&mut out, 100, 48_000);
for (k, v) in out[0].iter().enumerate() {
assert_eq!(*v, (2 * k) as f32, "output {k}");
}
}
/// Same rate: samples pass through unchanged.
#[test]
fn monitor_passes_through_at_equal_rates() {
let ring = InputRing::new(2, 48_000);
let input: Vec<f32> = (0..200).map(|i| i as f32).collect();
ring.push(&input);
let mut out = vec![vec![0.0f32; 50], vec![0.0f32; 50]];
ring.pop_into(&mut out, 50, 48_000);
assert_eq!(out[0][0], 0.0);
assert_eq!(out[1][0], 1.0);
assert_eq!(out[0][49], 98.0);
}
/// Nothing buffered: silence, not stale data.
#[test]
fn monitor_underrun_is_silence() {
let ring = InputRing::new(1, 16_000);
let mut out = vec![vec![1.0f32; 16]];
ring.pop_into(&mut out, 16, 48_000);
assert!(out[0].iter().all(|v| *v == 0.0));
}
}
+1
View File
@@ -21,6 +21,7 @@ soundcraft-engine.workspace = true
soundcraft-playback.workspace = true
soundcraft-video.workspace = true
egui.workspace = true
dark-light.workspace = true
serde.workspace = true
serde_json.workspace = true
log.workspace = true
+29 -2
View File
@@ -6,6 +6,7 @@ use egui::{Align2, vec2};
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_model::{ClipId, TrackId};
use soundcraft_time::Samples;
#[derive(Debug, Clone)]
pub enum Dialog {
@@ -16,6 +17,7 @@ pub enum Dialog {
RenameClip { id: ClipId, name: 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
Fades { shape: String },
StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 },
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
@@ -38,6 +40,7 @@ impl Dialogs {
Dialog::RenameClip { .. } => "rename_clip",
Dialog::PathPrompt { .. } => "path",
Dialog::Number { .. } => "number",
Dialog::TempoChange { .. } => "tempo_change",
Dialog::Fades { .. } => "fades",
Dialog::StripSilence { .. } => "strip_silence",
Dialog::Group { .. } => "group",
@@ -167,6 +170,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::RenameTrack { .. } => "Rename Track",
Dialog::RenameClip { .. } => "Rename Clip",
Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),
Dialog::TempoChange { .. } => "Tempo Change (BPM)",
Dialog::Fades { .. } => "Fades",
Dialog::StripSilence { .. } => "Strip Silence",
Dialog::Group { .. } => "Create Group",
@@ -258,7 +262,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
ui.label(
egui::RichText::new("Bounces the edit selection, or the whole session when nothing is selected.")
.small()
.color(Tokens::DARK.text_dim),
.color(Tokens::current().text_dim),
);
if buttons(ui, "Bounce", enter) {
let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() });
@@ -315,6 +319,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
action = Some((cmd.clone(), v));
}
}
Dialog::TempoChange { at, bpm } => {
ui.horizontal(|ui| {
// Same limits the engine enforces (time crate `valid_bpm`).
ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0));
ui.label("bpm");
});
if buttons(ui, "OK", enter) {
action = Some(tempo_change_action(*at, *bpm));
}
}
Dialog::Fades { shape } => {
for s in soundcraft_model::FadeShape::ALL {
ui.radio_value(shape, s.label().to_lowercase(), s.label());
@@ -415,13 +429,18 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
if ui.button("Cancel").clicked() {
ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::new("dlg_cancel"), true));
}
if ui.add(egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::DARK.accent)).clicked() {
if ui.add(egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent)).clicked() {
pressed = true;
}
});
pressed
}
/// 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}))
}
fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
let Some(process) = app.ui.audiosuite.clone() else { return };
let mut open = true;
@@ -495,4 +514,12 @@ mod tests {
assert!(d.open_for_command(&e, "file.score_setup"));
assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. })));
}
#[test]
fn tempo_change_dialog_runs_a_real_command_at_its_position() {
let (cmd, params) = tempo_change_action(96_000, 133.5);
assert!(soundcraft_engine::command_specs().iter().any(|c| c.id == cmd), "{cmd} is not a command");
assert_eq!(params["at"], 96_000);
assert_eq!(params["bpm"], 133.5);
}
}
+223 -39
View File
@@ -32,6 +32,18 @@ pub struct EditLayout {
pub rows: Vec<(u64, [f32; 4])>,
pub scroll_y: f32,
pub content_h: f32,
/// Follow-hold: the view was moved by something other than the playhead
/// follow (a manual pan, scrollbar, universe jump, or programmatic
/// scroll) during playback, so timeline auto-scroll stays paused until
/// the playhead is scrolled back into view (or the transport
/// stops/starts, or a scrolling mode is re-selected). Lets the timeline
/// be inspected anywhere while playing.
pub follow_hold: bool,
/// Session `zoom.scroll` seen by the last overlay frame, for telling our
/// own follow jumps apart from outside view moves.
pub last_scroll: Samples,
/// Follow target issued by the last overlay frame (`None` when none).
pub last_follow_to: Option<Samples>,
}
pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 {
@@ -50,7 +62,7 @@ pub fn track_height(t: &Track) -> f32 {
}
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
egui::Panel::top("edit_toolbar").exact_size(toolbar::HEIGHT).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| toolbar::show(app, ui));
egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui));
egui::Panel::bottom("lower_dock_tabs").exact_size(22.0).frame(egui::Frame::NONE.fill(t.panel_bg2)).show(ui, |ui| dock_tabs(app, ui));
@@ -63,7 +75,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
.show(ui, |ui| crate::midi_editor::show(app, ui));
}
if app.ui.show_universe {
egui::Panel::top("universe").exact_size(64.0).frame(egui::Frame::NONE.fill(Color32::from_rgb(18, 18, 19))).show(ui, |ui| universe(app, ui));
egui::Panel::top("universe").exact_size(64.0).frame(egui::Frame::NONE.fill(Tokens::current().universe_bg)).show(ui, |ui| universe(app, ui));
}
if app.ui.show_tracks_list {
egui::Panel::left("tracks_list")
@@ -101,10 +113,10 @@ fn universe(app: &mut SoundApp, ui: &mut Ui) {
let tlw = app.edit_layout.timeline[2] - app.edit_layout.timeline[0];
let vis1 = vis0 + (f64::from(tlw.max(100.0)) * s.edit.zoom.samples_per_px) as f32 / total;
let vr = Rect::from_min_max(pos2(r.min.x + vis0 * r.width(), r.min.y - 2.0), pos2(r.min.x + vis1.min(1.0) * r.width(), r.max.y + 2.0));
ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);
ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Tokens::current().universe_view), StrokeKind::Outside);
if app.is_playing() {
let x = r.min.x + app.position() as f32 / total * r.width();
ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Tokens::DARK.playhead));
ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Tokens::current().playhead));
}
let resp = ui.interact(r, ui.id().with("universe"), Sense::click_and_drag());
if let Some(p) = resp.interact_pointer_pos()
@@ -117,7 +129,7 @@ fn universe(app: &mut SoundApp, ui: &mut Ui) {
}
fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let r = ui.max_rect();
let mut x = r.min.x + 10.0;
for (label, on, id) in [
@@ -139,7 +151,7 @@ fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) {
}
fn status_bar(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let r = ui.max_rect();
let s = app.engine.session();
let left = format!(
@@ -166,7 +178,7 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) {
}
fn main_area(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let full = ui.max_rect();
let rulers: Vec<String> = app.engine.session().edit.rulers.clone();
let mut rulers = rulers;
@@ -251,7 +263,7 @@ fn folder_collapsed(s: &Session, t: &Track) -> bool {
}
fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) {
let t = Tokens::DARK;
let t = Tokens::current();
ui.painter().rect_filled(sb, 0.0, t.panel_bg);
let s = app.engine.session();
let total = (s.content_end() + s.sample_rate.samples(30.0)).max(1) as f64;
@@ -349,7 +361,7 @@ fn nice_step(spp: f64, sr: f64, min_px: f64) -> i64 {
}
fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[String]) {
let t = Tokens::DARK;
let t = Tokens::current();
let s = app.engine.session().clone();
ui.painter().rect_filled(area, 0.0, t.ruler_bg);
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
@@ -372,13 +384,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
{
let at = snap(&s, sample_at(&s, tl, p.x).max(0));
let bpm = s.tempo.tempo_at_tick(s.tempo.samples_to_ticks(at, sr));
app.dialogs.open = Some(crate::dialogs::Dialog::Number {
cmd: format!("event.tempo@{at}"),
title: "Tempo Change (BPM)".into(),
key: "bpm".into(),
value: bpm,
suffix: "bpm".into(),
});
app.dialogs.open = Some(crate::dialogs::Dialog::TempoChange { at, bpm });
}
let mut last_label = f32::MIN;
for ev in s.tempo.tempos() {
@@ -472,7 +478,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
col,
Stroke::NONE,
));
painter.text(pos2(x + 8.0, row.center().y), Align2::LEFT_CENTER, &m.name, bold(11.0), Color32::from_rgb(236, 236, 236));
painter.text(pos2(x + 8.0, row.center().y), Align2::LEFT_CENTER, &m.name, bold(11.0), t.marker_text);
}
// Click on the marker ruler: recall a marker; double-click adds one.
let resp = ui.interact(row, ui.id().with(("marker_ruler", lane)), Sense::click());
@@ -516,7 +522,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
}
fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: Rect, view: Range, f: TimeFormat, bottom: bool) {
let t = Tokens::DARK;
let t = Tokens::current();
let sr = s.sample_rate;
let spp = s.edit.zoom.samples_per_px;
let mut marks: Vec<(Samples, bool)> = Vec::new();
@@ -615,7 +621,7 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row:
// ---- track rows ------------------------------------------------------------------------------
fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) {
let t = Tokens::DARK;
let t = Tokens::current();
let s = app.engine.session();
let Some(track) = s.track(id).cloned() else { return };
let selected = s.edit.selected_tracks.contains(&id);
@@ -628,7 +634,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect)
let main_h = track.height.points();
let lane = Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.min.y + main_h));
// Header background.
ui.painter().rect_filled(head, 0.0, if selected { Color32::from_rgb(52, 58, 66) } else { t.panel_bg2 });
ui.painter().rect_filled(head, 0.0, if selected { t.header_selected } else { t.panel_bg2 });
ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color));
ui.painter().line_segment([pos2(head.min.x, row.max.y + 0.5), pos2(tl.max.x, row.max.y + 0.5)], Stroke::new(1.0, t.border));
track_header(app, ui, &track, head, selected);
@@ -670,7 +676,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect)
/// Alternate playlists as lanes under the main one; clicking a clip promotes it (comping).
fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, main_h: f32, tl: Rect) {
let t = Tokens::DARK;
let t = Tokens::current();
let s = app.engine.session().clone();
let mut y = row.min.y + main_h;
for (i, pl) in track.playlists.iter().enumerate() {
@@ -679,7 +685,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai
}
let lane = Rect::from_min_max(pos2(tl.min.x, y), pos2(tl.max.x, y + LANE_H));
let head = Rect::from_min_max(pos2(row.min.x + 12.0, y), pos2(tl.min.x, y + LANE_H));
ui.painter().rect_filled(lane, 0.0, Color32::from_rgb(30, 30, 31));
ui.painter().rect_filled(lane, 0.0, t.playlist_lane);
ui.painter().text(pos2(head.min.x + 4.0, head.center().y), Align2::LEFT_CENTER, &pl.name, regular(10.5), t.text_dim);
let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect()));
for c in &pl.clips {
@@ -708,7 +714,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai
}
fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool) {
let t = Tokens::DARK;
let t = Tokens::current();
let id = track.id;
// Drag the bottom edge to resize (snaps to the height presets).
let edge = Rect::from_min_max(pos2(head.min.x, head.max.y - 4.0), pos2(head.max.x, head.max.y + 2.0));
@@ -778,7 +784,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
.unwrap_or(p.y);
ui.ctx()
.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("reorder")))
.line_segment([pos2(head.min.x, line_y), pos2(head.max.x + 600.0, line_y)], Stroke::new(3.0, Tokens::DARK.accent));
.line_segment([pos2(head.min.x, line_y), pos2(head.max.x + 600.0, line_y)], Stroke::new(3.0, Tokens::current().accent));
}
if nresp.drag_stopped()
&& let Some(p) = ui.ctx().pointer_latest_pos()
@@ -833,7 +839,15 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
TrackKind::Video => "VIDEO",
};
if !kind_label.is_empty() {
ui.painter().text(pos2(pl_r.max.x + 4.0, name_r.center().y), Align2::LEFT_CENTER, kind_label, bold(9.0), t.text_dim);
let kr = ui.painter().text(pos2(pl_r.max.x + 4.0, name_r.center().y), Align2::LEFT_CENTER, kind_label, bold(9.0), t.text_dim);
if track.kind == TrackKind::Instrument {
// The instrument picker (and the instrument's editor) behind the INST label.
let name = track.instrument.as_ref().and_then(|i| crate::mix_window::plugin_info(&i.plugin)).map_or("none", |p| p.name);
let resp = ui
.interact(kr.expand(3.0), ui.id().with(("instrument", id.0)), Sense::click())
.on_hover_text(format!("Instrument: {name} (click to change or open its editor)"));
egui::Popup::menu(&resp).show(|ui| crate::mix_window::instrument_menu(app, ui, id));
}
}
if h < 30.0 {
return;
@@ -919,7 +933,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
/// One optional Edit-window column for a track (I/O, inserts, sends, comments).
fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect) {
let t = Tokens::DARK;
let t = Tokens::current();
ui.painter().rect_filled(r, 0.0, t.panel_bg);
ui.painter().line_segment([pos2(r.min.x, r.min.y), pos2(r.min.x, r.max.y)], Stroke::new(1.0, t.border));
let line_h = 15.0;
@@ -1062,7 +1076,7 @@ fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
}
fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) {
let t = Tokens::DARK;
let t = Tokens::current();
let view = Range::new(sample_at(s, tl, tl.min.x).max(0), sample_at(s, tl, tl.max.x));
let sr = s.sample_rate;
// Bar lines always; grid lines when enabled and dense enough.
@@ -1219,9 +1233,12 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
pts.push(pos2(x1, gy(clip.gain_db + clip.gain_env.last().map_or(0.0, |p| p.1))));
painter.add(Shape::line(pts, Stroke::new(1.0, col)));
}
if s.edit.flag("view.clip.gain_info") || clip.gain_db.abs() > 0.01 {
painter.text(pos2(r.max.x - 4.0, body_r.max.y - 8.0), Align2::RIGHT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col);
}
}
if !dim && let Some(icon) = clip_gain_icon(s, tl, lane, clip) {
let col = Color32::from_rgba_unmultiplied(255, 255, 255, 200);
painter.line_segment([pos2(icon.center().x, icon.min.y), pos2(icon.center().x, icon.max.y)], Stroke::new(1.0, col));
painter.rect_filled(Rect::from_center_size(pos2(icon.center().x, icon.center().y + 2.0), vec2(icon.width(), 3.0)), 1.0, col);
painter.text(pos2(icon.max.x + 3.0, icon.center().y), Align2::LEFT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col);
}
// Fades.
let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90);
@@ -1301,7 +1318,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
}
fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &AutoParam, tl: Rect, lane: Rect) {
let t = Tokens::DARK;
let t = Tokens::current();
let (lo, hi, _) = p.range();
let (lo, hi) = if matches!(p, AutoParam::Volume | AutoParam::SendLevel(_)) {
(-60.0f32, 12.0f32)
@@ -1369,9 +1386,48 @@ fn clip_at(track: &Track, at: Samples) -> Option<&Clip> {
track.clips().iter().rev().find(|c| c.range().contains(at))
}
pub const CLIP_FADER_TRAVEL: f32 = 200.0;
/// Bottom-left of an audio clip's visible part, when Clip Gain Info is on or the clip has gain and it fits.
pub fn clip_gain_icon(s: &Session, tl: Rect, lane: Rect, clip: &Clip) -> Option<Rect> {
let x0 = x_of(s, tl, clip.start).max(lane.min.x);
let x1 = x_of(s, tl, clip.end()).min(lane.max.x);
let shown = matches!(clip.content, ClipContent::Audio { .. }) && (s.edit.flag("view.clip.gain_info") || clip.gain_db.abs() > 0.01);
(shown && x1 - x0 >= 48.0 && lane.height() >= 30.0).then(|| Rect::from_min_size(pos2(x0 + 4.0, lane.max.y - 16.0), vec2(9.0, 13.0)))
}
fn clip_gain_popup(ctx: &egui::Context, fader_bottom: Pos2, pos: f32) {
let t = Tokens::current();
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("clip_gain_fader")));
let track = Rect::from_min_max(pos2(fader_bottom.x - 2.0, fader_bottom.y - CLIP_FADER_TRAVEL), pos2(fader_bottom.x + 2.0, fader_bottom.y));
let panel = Rect::from_min_max(pos2(track.min.x - 22.0, track.min.y - 26.0), pos2(track.max.x + 22.0, track.max.y + 8.0));
p.rect(panel, 3.0, Color32::from_rgb(38, 38, 40), Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside);
p.rect_filled(track, 2.0, t.fader_track);
let zero = track.max.y - CLIP_FADER_TRAVEL * crate::widgets::clip_gain_to_pos(0.0);
p.line_segment([pos2(track.min.x - 8.0, zero), pos2(track.min.x - 2.0, zero)], Stroke::new(1.0, Color32::from_rgb(150, 150, 150)));
let cap = Rect::from_center_size(pos2(track.center().x, track.max.y - CLIP_FADER_TRAVEL * pos), vec2(22.0, 9.0));
p.rect(cap, 2.0, Color32::from_rgb(210, 210, 210), Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), StrokeKind::Inside);
let db = crate::widgets::clip_gain_from_pos(pos);
p.text(pos2(panel.center().x, panel.min.y + 12.0), Align2::CENTER_CENTER, format!("{db:+.1}"), regular(10.5), t.counter_text);
}
fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) {
let id = ui.id().with(("lane", track.id.0));
let resp = ui.interact(lane, id, Sense::click_and_drag());
// The clip menu is attached before the early return below: once the menu is open the pointer
// is over the popup, the lane has no hover position, and the rest of this function bails out.
// The clip is remembered at the right-click so the menu keeps its target while it is used.
let menu_clip_id = id.with("menu_clip");
if resp.secondary_clicked() {
let clip = resp.interact_pointer_pos().and_then(|p| {
let s = app.engine.session();
clip_at(track, sample_at(s, tl, p.x).max(0)).map(|c| c.id)
});
ui.ctx().data_mut(|d| d.insert_temp(menu_clip_id, clip));
}
// The clip may have been removed or moved since the right-click; only use it if it still exists.
let menu_clip = ui.ctx().data(|d| d.get_temp::<Option<ClipId>>(menu_clip_id)).flatten().filter(|c| track.clips().iter().any(|x| x.id == *c));
resp.context_menu(|ui| clip_context_menu(app, ui, menu_clip));
// Audio files dropped from the Clip List.
if let Some(src) = resp.dnd_release_payload::<crate::DragSource>()
&& let Some(p) = ui.ctx().pointer_latest_pos()
@@ -1381,7 +1437,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
let _ = app.run("clip.place_source", json!({"source": src.0, "track": track.id.0, "at": at}));
}
if resp.dnd_hover_payload::<crate::DragSource>().is_some() {
ui.painter().rect_stroke(lane, 0.0, Stroke::new(2.0, Tokens::DARK.accent), StrokeKind::Inside);
ui.painter().rect_stroke(lane, 0.0, Stroke::new(2.0, Tokens::current().accent), StrokeKind::Inside);
}
let s = app.engine.session().clone();
let tool = s.edit.tool;
@@ -1451,7 +1507,27 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
if fade_corner.is_some() && resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe);
}
if resp.drag_started()
let gain_icon_at = |q: Pos2| {
let c = clip_at(track, sample_at(&s, tl, q.x).max(0)).filter(|_| auto_view.is_none())?;
clip_gain_icon(&s, tl, lane, c).filter(|r| r.contains(q)).map(|_| c.clone())
};
if resp.hovered() && gain_icon_at(p).is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(p);
let gain_drag = if resp.drag_started() { gain_icon_at(press) } else { None };
if let Some(c) = gain_drag {
// `clip.gain` refuses to run with nothing selected.
if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() {
let _ = app.engine.execute("edit.select", &json!({"clips": [c.id.0]}));
}
let pos = crate::widgets::clip_gain_to_pos(c.gain_db);
let screen = ui.ctx().content_rect();
let y = (press.y + CLIP_FADER_TRAVEL * pos).max(screen.min.y + CLIP_FADER_TRAVEL + 30.0).min(screen.max.y - 10.0);
let fader_bottom = pos2(press.x.max(screen.min.x + 26.0), y);
let fine = mods.command || mods.ctrl;
app.gesture = Some(Gesture::ClipGain { clip: c.id, track: track.id, pos, anchor: (press.y, pos), fine, fader_bottom });
} else if resp.drag_started()
&& let (Some(fade_in), Some(c)) = (fade_corner, hit.as_ref())
{
app.gesture = Some(Gesture::Fade { clip: c.id, track: track.id, fade_in, to: at });
@@ -1501,6 +1577,19 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
}
Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at),
Some(Gesture::Fade { to, .. }) => *to = at,
Some(Gesture::ClipGain { clip, pos, anchor, fine, .. }) => {
// The fader follows the pointer from where it was pressed; ⌘ re-anchors for fine moves.
if *fine != (mods.command || mods.ctrl) {
*fine = !*fine;
*anchor = (p.y, *pos);
}
let scale = if *fine { 0.1 } else { 1.0 };
*pos = (anchor.1 + (anchor.0 - p.y) / CLIP_FADER_TRAVEL * scale).clamp(0.0, 1.0);
let db = crate::widgets::clip_gain_from_pos(*pos);
if s.find_clip(*clip).is_some_and(|(_, c)| (c.gain_db - db).abs() > 0.001) {
let _ = app.engine.execute_merged("clip.gain", &json!({"clips": [clip.0], "db": db}), &format!("clip_gain:{}", clip.0));
}
}
Some(Gesture::Scrub { last }) => {
*last = at;
let _ = app.engine.execute("transport.locate", &json!({"at": at}));
@@ -1531,6 +1620,13 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
let _ = app.engine.execute("edit.select", &json!({"tracks": ids, "start": r.start, "end": r.end}));
}
}
if let Some(Gesture::ClipGain { track: t, pos, fader_bottom, .. }) = &app.gesture
&& *t == track.id
&& resp.dragged()
{
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
clip_gain_popup(ui.ctx(), *fader_bottom, *pos);
}
if resp.drag_stopped() {
let g = app.gesture.take();
match g {
@@ -1613,7 +1709,6 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
{
let _ = app.run("edit.select", json!({"clips": [c.id.0]}));
}
resp.context_menu(|ui| clip_context_menu(app, ui, hit.as_ref().map(|c| c.id)));
}
fn automation_id(p: &AutoParam) -> String {
@@ -1679,9 +1774,46 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
}
}
/// Next follow state after observing one frame: `(hold, follow_to)`.
///
/// The view is ours to move only while nothing else moved it: when the
/// session scroll differs from both the last seen value and our own last
/// follow target, something else (a manual pan, scrollbar, universe jump,
/// or programmatic scroll) took over, so the follow pauses (`hold`) and no
/// jump is issued. Stopping, or scrolling back to the playhead, clears the
/// hold. Only `page`/`continuous`/`center` ever issue follow jumps while
/// playing; `none`/`after_playback` leave the view alone.
fn follow_update(
playing: bool,
mode_follow: bool,
playhead_visible: bool,
cur: Samples,
last_scroll: Samples,
last_follow_to: Option<Samples>,
hold: bool,
pos: Samples,
) -> (bool, Option<Samples>) {
if !playing || playhead_visible {
return (false, None);
}
if cur != last_scroll && Some(cur) != last_follow_to {
return (mode_follow, None);
}
if hold || !mode_follow {
return (hold, None);
}
(false, Some(pos))
}
/// Selection overlay, insertion point and playhead across rulers and tracks.
/// While playing, auto-scroll keeps the playhead in view for the follow
/// modes (`page`/`continuous`/`center`) — but any outside view move (a manual
/// pan, scrollbar, universe jump, or programmatic scroll) pauses it, so the
/// timeline can be inspected anywhere during playback. Scrolling back to the
/// playhead, stopping/starting, or re-selecting a scrolling mode resumes the
/// follow. `none` and `after_playback` never scroll during playback.
fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
let t = Tokens::DARK;
let t = Tokens::current();
let mut scroll_to: Option<Samples> = None;
let s = app.engine.session();
let painter = ui.painter().with_clip_rect(area);
@@ -1721,10 +1853,18 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
if app.is_playing() {
let x = x_of(s, tl, app.position());
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, t.playhead));
// Page scrolling.
if s.edit.scrolling != "none" && (x > tl.max.x || x < tl.min.x) {
scroll_to = Some(app.position());
}
// Session reads first (owned copies), then the layout mutation: the
// two borrow disjoint fields.
let pos = app.position();
let cur = s.edit.zoom.scroll;
let mode_follow = matches!(s.edit.scrolling.as_str(), "page" | "continuous" | "center");
let visible = x >= tl.min.x && x <= tl.max.x;
let layout = &mut app.edit_layout;
let (hold, follow_to) = follow_update(true, mode_follow, visible, cur, layout.last_scroll, layout.last_follow_to, layout.follow_hold, pos);
layout.follow_hold = hold;
layout.last_scroll = cur;
layout.last_follow_to = follow_to;
scroll_to = follow_to;
}
// Ghost of a clip move.
if let Some(Gesture::MoveClips { clips, delta, to_track, .. }) = &app.gesture {
@@ -1763,3 +1903,47 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
let _ = app.engine.execute("view.scroll", &json!({"to": at}));
}
}
#[cfg(test)]
mod tests {
use super::follow_update;
#[test]
fn follow_jumps_to_offscreen_playhead_while_playing() {
// Steady view, follow mode, playhead left the screen: jump to it.
assert_eq!(follow_update(true, true, false, 1_000, 1_000, None, false, 96_000), (false, Some(96_000)));
}
#[test]
fn follow_leaves_onscreen_playhead_alone() {
assert_eq!(follow_update(true, true, true, 1_000, 1_000, None, false, 96_000), (false, None));
}
#[test]
fn follow_never_scrolls_when_stopped_nor_in_free_modes() {
assert_eq!(follow_update(false, true, false, 1_000, 1_000, None, false, 96_000), (false, None));
// `none`/`after_playback` never follow, even with a steady view.
assert_eq!(follow_update(true, false, false, 1_000, 1_000, None, false, 96_000), (false, None));
assert_eq!(follow_update(true, false, false, 9_000, 1_000, None, false, 96_000), (false, None));
}
#[test]
fn outside_view_move_pauses_the_follow() {
// The view moved and it was not our own jump: pause, no jump.
assert_eq!(follow_update(true, true, false, 9_000, 1_000, None, false, 96_000), (true, None));
// A held view stays held while it sits still elsewhere.
assert_eq!(follow_update(true, true, false, 9_000, 9_000, None, true, 96_000), (true, None));
}
#[test]
fn own_follow_jump_is_not_mistaken_for_a_takeover() {
// The scroll equals the target we issued last frame: keep following.
assert_eq!(follow_update(true, true, false, 96_000, 1_000, Some(96_000), false, 97_000), (false, Some(97_000)));
}
#[test]
fn hold_clears_when_playhead_returns_or_transport_stops() {
assert_eq!(follow_update(true, true, true, 9_000, 9_000, None, true, 96_000), (false, None));
assert_eq!(follow_update(false, true, false, 9_000, 9_000, None, true, 96_000), (false, None));
}
}
+20 -7
View File
@@ -87,7 +87,7 @@ fn renderer(app: &mut SoundApp, ctx: &egui::Context) {
if !open {
return;
}
let t = Tokens::DARK;
let t = Tokens::current();
let fmt = app.engine.session().main_format();
let peaks: Vec<f32> = app.player.as_ref().map(|p| p.meters().main.peaks).unwrap_or_default();
let objects: Vec<(String, f32, f32, f32)> = app
@@ -149,11 +149,9 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
if !open {
return;
}
egui::Window::new(egui::RichText::new("UI Customization").font(bold(13.0)))
.open(&mut open)
.default_size(vec2(320.0, 180.0))
.resizable(false)
.show(ctx, |ui| {
egui::Window::new(egui::RichText::new("Appearance").font(bold(13.0))).open(&mut open).default_size(vec2(320.0, 180.0)).resizable(false).show(
ctx,
|ui| {
egui::Grid::new("ui_custom").num_columns(2).spacing(vec2(12.0, 8.0)).show(ui, |ui| {
ui.label("Interface scale");
ui.add(egui::Slider::new(&mut app.extra.ui_scale, 0.75..=1.5).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)));
@@ -161,6 +159,19 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
ui.label("Reduce motion");
ui.checkbox(&mut app.extra.reduce_motion, "");
ui.end_row();
ui.label("Theme");
let mut chosen = None;
egui::ComboBox::from_id_salt("theme_mode").selected_text(app.ui.theme.label()).show_ui(ui, |ui| {
for mode in crate::theme::ThemeMode::ALL {
if ui.selectable_label(app.ui.theme == mode, mode.label()).clicked() {
chosen = Some(mode);
}
}
});
if let Some(mode) = chosen {
let _ = app.run("ui.theme", json!({"mode": mode.id()}));
}
ui.end_row();
ui.label("Auto-update configuration");
ui.checkbox(&mut app.extra.auto_update_config, "");
ui.end_row();
@@ -168,7 +179,9 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
if ui.button("Reset").clicked() {
app.extra.ui_scale = 1.0;
app.extra.reduce_motion = false;
let _ = app.run("ui.theme", json!({"mode": crate::theme::ThemeMode::default().id()}));
}
});
},
);
app.extra.show_ui_customization = open;
}
+34 -4
View File
@@ -26,11 +26,41 @@ const BOLD: &[(&str, u32)] = &[
("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", 0),
];
/// Asks fontconfig for the desktop's UI font. Only TrueType/OpenType results are used: egui cannot
/// read Type 1 or bitmap fonts, which `fc-match` may return on minimal systems.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn load_from_fontconfig(bold: bool) -> Option<FontData> {
let pattern = if bold { "sans-serif:style=Bold" } else { "sans-serif" };
let output = std::process::Command::new("fc-match").args(["--format=%{file}\\n%{index}\\n", pattern]).output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let mut lines = stdout.lines();
let path = lines.next()?.trim();
// The upper 16 bits carry a variable font's named instance; the face index is the lower 16.
let index = lines.next()?.trim().parse::<u32>().ok()? & 0xFFFF;
let bytes = std::fs::read(path).ok()?;
let sfnt = matches!(bytes.get(..4), Some(b"\0\x01\0\0" | b"OTTO" | b"true" | b"ttcf"));
if !sfnt {
return None;
}
let mut font = FontData::from_owned(bytes);
font.index = index;
Some(font)
}
#[cfg(not(target_arch = "wasm32"))]
fn load(cands: &[(&str, u32)]) -> Option<FontData> {
fn load(cands: &[(&str, u32)], bold: bool) -> Option<FontData> {
if std::env::var_os("SOUNDCRAFT_NO_SYSTEM_FONTS").is_some() {
return None;
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
if let Some(font) = load_from_fontconfig(bold) {
return Some(font);
}
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
let _ = bold;
for (p, idx) in cands {
if let Ok(bytes) = std::fs::read(p) {
let mut fd = FontData::from_owned(bytes);
@@ -42,7 +72,7 @@ fn load(cands: &[(&str, u32)]) -> Option<FontData> {
}
#[cfg(target_arch = "wasm32")]
fn load(_: &[(&str, u32)]) -> Option<FontData> {
fn load(_: &[(&str, u32)], _: bool) -> Option<FontData> {
None
}
@@ -50,13 +80,13 @@ pub fn definitions() -> FontDefinitions {
let mut fonts = FontDefinitions::default();
let base: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let mut bold_family = base.clone();
if let Some(reg) = load(REGULAR) {
if let Some(reg) = load(REGULAR, false) {
fonts.font_data.insert("system-regular".into(), Arc::new(reg));
if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) {
f.insert(0, "system-regular".into());
}
}
if let Some(b) = load(BOLD) {
if let Some(b) = load(BOLD, true) {
fonts.font_data.insert("system-bold".into(), Arc::new(b));
bold_family.insert(0, "system-bold".into());
} else if fonts.font_data.contains_key("system-regular") {
+91 -5
View File
@@ -55,6 +55,7 @@ pub struct UiState {
pub show_big_counter: bool,
pub show_undo_history: bool,
pub show_session_info: bool,
pub show_audio_health: bool,
pub show_about: bool,
pub show_midi_editor: bool,
pub show_universe: bool,
@@ -94,6 +95,8 @@ pub struct UiState {
pub video_burn_in: bool,
pub workspace_dir: String,
pub configurations: Vec<(String, Value)>,
/// Appearance (`ui.theme`): dark by default, light or follow the system on request.
pub theme: theme::ThemeMode,
}
impl Default for UiState {
@@ -107,6 +110,7 @@ impl Default for UiState {
show_big_counter: false,
show_undo_history: false,
show_session_info: false,
show_audio_health: false,
show_about: false,
show_midi_editor: false,
show_universe: false,
@@ -142,6 +146,7 @@ impl Default for UiState {
video_burn_in: false,
workspace_dir: String::new(),
configurations: Vec::new(),
theme: theme::ThemeMode::Dark,
}
}
}
@@ -182,6 +187,7 @@ pub enum Gesture {
Fade { clip: soundcraft_model::ClipId, track: TrackId, fade_in: bool, to: Samples },
Scrub { last: Samples },
Fader { track: TrackId, start_db: f32 },
ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 },
Pencil { track: TrackId, points: Vec<(Samples, f32)> },
ZoomBox { start: f32 },
}
@@ -196,7 +202,10 @@ pub struct SoundApp {
punch: Option<Range>,
recorder_failed: bool,
pub ui: UiState,
pub audio_health_report: Option<Result<Value, String>>,
pub services: Services,
/// Set only by a desktop host after installing an OS menu bar.
pub native_menu_bar: bool,
/// Folder for crash-recovery autosaves (native apps set it).
pub autosave_dir: Option<std::path::PathBuf>,
/// Folder for user plugin presets (native apps set it).
@@ -218,6 +227,8 @@ pub struct SoundApp {
last_rev: u64,
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<bool>,
/// Transport simulation when there is no player (tests, offscreen renders).
sim: Option<(Samples, Option<Samples>, Option<Range>)>,
pub quit_requested: bool,
@@ -232,6 +243,16 @@ pub struct SoundApp {
pub video: video_track::VideoPool,
}
/// Whether another automation-write pass is due: the transport moved at least
/// `step` since `last` in either direction. `last == i64::MIN` marks "never
/// written", so the first pass after (re)start is always due. Saturating
/// arithmetic keeps restarts and loop wraps overflow-free: the previous
/// `(pos - last).abs()` panicked in debug builds on the first playing frame,
/// when `last` is still `i64::MIN`.
fn automation_step_due(pos: Samples, last: Samples, step: Samples) -> bool {
last == i64::MIN || pos.saturating_sub(last) >= step || last.saturating_sub(pos) >= step
}
impl SoundApp {
pub fn new(engine: Engine, player: Option<Player>, services: Services) -> Self {
SoundApp {
@@ -242,7 +263,9 @@ impl SoundApp {
punch: None,
recorder_failed: false,
ui: UiState::default(),
audio_health_report: None,
services,
native_menu_bar: false,
autosave_dir: None,
preset_dir: None,
last_autosave: 0.0,
@@ -262,6 +285,7 @@ impl SoundApp {
last_rev: 0,
fonts_ready: false,
fonts_installed: false,
theme_applied: None,
sim: None,
quit_requested: false,
arrange_request: false,
@@ -292,6 +316,10 @@ impl SoundApp {
if id == "app.quit" {
self.quit_requested = true;
}
if id == "options.scrolling" {
// Re-selecting a scrolling mode resumes the playhead follow.
self.edit_layout.follow_hold = false;
}
Ok(v)
}
Err(e) => {
@@ -320,6 +348,12 @@ impl SoundApp {
}
self.engine.transport.playing = true;
self.engine.transport.position = from;
// A fresh start resumes the playhead follow: clear a manual hold and
// re-sync the follow tracker so the current view is not mistaken for
// an outside move on the first frame.
self.edit_layout.follow_hold = false;
self.edit_layout.last_scroll = self.engine.session().edit.zoom.scroll;
self.edit_layout.last_follow_to = None;
}
fn stop_play(&mut self) {
@@ -332,11 +366,33 @@ impl SoundApp {
self.sim = None;
self.engine.transport.playing = false;
self.engine.transport.recording = false;
let s = self.engine.session();
if s.edit.insertion_follows_playback {
let stop_at = self.engine.transport.position;
// Snapshot the session flags first: `session_mut` below needs a
// mutable borrow, so no session borrow may span it.
let (follow_insertion, after_playback) = {
let s = self.engine.session();
(s.edit.insertion_follows_playback, s.edit.scrolling == "after_playback")
};
if follow_insertion {
let pos = self.engine.transport.position;
self.engine.session_mut().edit.selection = Range::point(pos);
}
if after_playback {
// After Playback never scrolls while playing, so bring the stopped
// position into view now (only if it is off-screen); every other
// mode keeps its view.
let at = stop_at.max(0);
let (scroll, spp) = {
let z = &self.engine.session().edit.zoom;
(z.scroll, z.samples_per_px.max(0.01))
};
let width = f64::from(self.edit_layout.timeline[2] - self.edit_layout.timeline[0]).max(0.0);
let end = scroll.saturating_add((width * spp) as Samples);
if at < scroll || at >= end {
let _ = self.engine.execute("view.scroll", &json!({ "to": at }));
}
}
self.edit_layout.follow_hold = false;
}
fn start_recording(&mut self) {
@@ -531,7 +587,7 @@ impl SoundApp {
}
let pos = self.position();
let step = self.engine.session().sample_rate.samples(0.02);
if (pos - self.last_write_at).abs() < step {
if !automation_step_due(pos, self.last_write_at, step) {
return;
}
self.last_write_at = pos;
@@ -681,11 +737,15 @@ impl SoundApp {
self.fonts_ready = true;
} else {
fonts::install(ctx);
theme::apply(ctx);
self.fonts_installed = true;
ctx.request_repaint();
}
}
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);
@@ -767,7 +827,9 @@ impl SoundApp {
ctx.memory_mut(|m| m.reset_areas());
}
shortcuts::handle(self, &ctx);
menus::menu_bar(self, ui);
if !self.native_menu_bar {
menus::menu_bar(self, ui);
}
match self.ui.window {
MainWindow::Edit => edit_window::show(self, ui),
MainWindow::Mix => mix_window::show(self, ui),
@@ -824,3 +886,27 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) {
}
d.gr = if gr > d.gr { gr } else { d.gr * fall };
}
#[cfg(test)]
mod tests {
use super::automation_step_due;
#[test]
fn automation_first_pass_after_start_is_due_without_overflow() {
// `last_write_at` starts (and resets) at `i64::MIN`: the previous
// `(pos - last).abs()` panicked here in debug builds.
assert!(automation_step_due(0, i64::MIN, 960));
assert!(automation_step_due(48_000, i64::MIN, 960));
}
#[test]
fn automation_write_throttles_to_step() {
assert!(!automation_step_due(1_000, 900, 960));
assert!(automation_step_due(2_000, 900, 960));
}
#[test]
fn automation_write_fires_after_loop_wrap() {
assert!(automation_step_due(0, 1_000_000, 960));
}
}
+79 -37
View File
@@ -19,6 +19,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("window.clip_list_view", "Clip List", "View > Other Displays > Clip List", None),
("window.narrow_mix", "Narrow Mix", "View > Narrow Mix", Some("Cmd+Alt+M")),
("window.session_info", "Session Info", "", None),
("window.audio_health", "Session Audio Health", "", None),
("window.about", "About SoundCraft", "", None),
("window.close", "Close Window", "Window > Close Window", Some("Cmd+W")),
("window.hide_floating", "Hide All Floating Windows", "Window > Hide All Floating Windows", Some("Cmd+Ctrl+W")),
@@ -67,6 +68,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("ui.new_tracks_dialog", "New Tracks…", "", None),
("ui.bounce_dialog", "Bounce Mix…", "", None),
("ui.set", "Set UI State", "", None),
("ui.theme", "Appearance", "", None),
];
/// Extra catalog mappings handled by the UI layer.
@@ -147,43 +149,50 @@ const DIALOG_COMMANDS: &[&str] = &[
"edit.strip_silence",
];
struct MenuNode {
label: String,
path: String,
children: Vec<MenuNode>,
/// Shared menu hierarchy for egui and native desktop hosts.
pub struct MenuNode {
pub label: String,
pub path: String,
pub children: Vec<MenuNode>,
}
fn tree() -> Vec<MenuNode> {
fn insert_path(roots: &mut Vec<MenuNode>, line: &str) {
let parts: Vec<&str> = line.split(" > ").collect();
let mut level = roots;
let mut path = String::new();
for (i, p) in parts.iter().enumerate() {
if i > 0 {
path.push_str(" > ");
}
path.push_str(p);
let idx = match level.iter().position(|n| n.label == *p) {
Some(i) => i,
None => {
level.push(MenuNode { label: p.to_string(), path: path.clone(), children: Vec::new() });
level.len() - 1
}
};
let Some(node) = level.get_mut(idx) else { break };
level = &mut node.children;
}
}
pub fn tree() -> Vec<MenuNode> {
let mut roots: Vec<MenuNode> = Vec::new();
for line in catalog::catalog() {
let parts: Vec<&str> = line.split(" > ").collect();
let mut level = &mut roots;
let mut path = String::new();
for (i, p) in parts.iter().enumerate() {
if i > 0 {
path.push_str(" > ");
}
path.push_str(p);
let idx = match level.iter().position(|n| n.label == *p) {
Some(i) => i,
None => {
level.push(MenuNode { label: p.to_string(), path: path.clone(), children: Vec::new() });
level.len() - 1
}
};
let Some(node) = level.get_mut(idx) else { break };
level = &mut node.children;
}
insert_path(&mut roots, line);
}
for (path, _) in MENU_WINDOWS {
insert_path(&mut roots, path);
}
roots
}
pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::DARK;
egui::Panel::top("menu_bar")
.exact_size(24.0)
.frame(egui::Frame::NONE.fill(egui::Color32::from_rgb(22, 22, 23)).inner_margin(egui::Margin::symmetric(8, 2)))
.show(ui, |ui| {
let t = crate::theme::Tokens::current();
egui::Panel::top("menu_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.menu_bar).inner_margin(egui::Margin::symmetric(8, 2))).show(
ui,
|ui| {
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("SoundCraft", |ui| {
if ui.button("About SoundCraft").clicked() {
@@ -192,6 +201,9 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
if ui.button("Session Info").clicked() {
app.ui.show_session_info = true;
}
if ui.button("Session Audio Health").clicked() {
let _ = app.run("window.audio_health", json!({"value": true}));
}
ui.separator();
if ui.button("Quit").clicked() {
let _ = app.run("app.quit", json!({}));
@@ -219,7 +231,8 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
ui.label(egui::RichText::new(title).color(t.text_dim));
});
});
});
},
);
}
fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) {
@@ -231,16 +244,11 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
});
return;
}
let id = catalog::implemented_by(&n.path, extra).or_else(|| MENU_WINDOWS.iter().find(|(p, _)| *p == n.path).map(|(_, w)| w.to_string()));
let enabled = match id.as_deref() {
Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true,
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()),
None => false,
};
let checked = checked_state(app, &n.path, id.as_deref());
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 btn = egui::Button::new(label).shortcut_text(shortcut);
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
{
@@ -249,6 +257,21 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
}
}
/// Resolve the command behind a menu leaf, including UI dialog overrides.
pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option<String> {
catalog::implemented_by(path, extra).or_else(|| MENU_WINDOWS.iter().find(|(p, _)| *p == path).map(|(_, w)| w.to_string()))
}
/// Enabled and checked state shared by all menu presentations.
pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) {
let enabled = match id {
Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true,
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()),
None => false,
};
(enabled, checked_state(app, path, id))
}
fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool {
let e = &app.engine.session().edit;
match id {
@@ -338,6 +361,7 @@ fn params_for(path: &str, id: &str) -> Value {
/// Menu items that open a SoundCraft window when clicked (programmatic calls still run the command).
const MENU_WINDOWS: &[(&str, &str)] = &[
("Setup > Appearance", "window.ui_customization"),
("Setup > Hardware...", "window.playback_engine"),
("Setup > Playback Engine...", "window.playback_engine"),
("Setup > I/O...", "window.io_setup"),
@@ -410,6 +434,10 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
"window.track_list" => toggle(&mut app.ui.show_tracks_list),
"window.narrow_mix" => toggle(&mut app.ui.narrow_mix),
"window.session_info" => toggle(&mut app.ui.show_session_info),
"window.audio_health" => {
app.audio_health_report = None;
toggle(&mut app.ui.show_audio_health)
}
"window.about" => toggle(&mut app.ui.show_about),
"window.midi_editor" | "window.midi_front" => toggle(&mut app.ui.show_midi_editor),
"window.midi_back" => {
@@ -471,6 +499,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
app.ui.show_memory_locations = false;
app.ui.show_undo_history = false;
app.ui.show_session_info = false;
app.ui.show_audio_health = false;
app.ui.plugin_windows.clear();
app.ui.audiosuite = None;
json!({})
@@ -502,6 +531,19 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix");
json!({})
}
"ui.theme" => {
let Some(mode) = p.get("mode").and_then(Value::as_str) else {
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}` (system, light or dark)")));
};
if mode == crate::theme::ThemeMode::System {
crate::theme::refresh_system();
}
app.ui.theme = mode;
json!({"mode": mode.id()})
}
"ui.set" => match serde_json::to_value(&app.ui) {
Ok(mut cur) => {
if let (Some(obj), Some(patch)) = (cur.as_object_mut(), p.as_object()) {
+1 -1
View File
@@ -56,7 +56,7 @@ fn is_black(p: i32) -> bool {
}
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let full = ui.max_rect();
ui.painter().rect_filled(full, 0.0, t.panel_bg);
let header = Rect::from_min_size(full.min, vec2(full.width(), 22.0));
+136 -39
View File
@@ -15,7 +15,7 @@ pub fn strip_width(narrow: bool) -> f32 {
}
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
if app.ui.show_tracks_list {
egui::Panel::left("mix_tracks_list")
.exact_size(168.0)
@@ -84,7 +84,7 @@ fn channel_levels(ui: &Ui, key: (&str, u64), peaks: &[f32], n: usize) -> Vec<f32
}
fn section_label(ui: &Ui, r: Rect, text: &str) {
ui.painter().text(pos2(r.center().x, r.min.y + 7.0), Align2::CENTER_CENTER, text, bold(10.0), Color32::from_rgb(200, 200, 200));
ui.painter().text(pos2(r.center().x, r.min.y + 7.0), Align2::CENTER_CENTER, text, bold(10.0), Tokens::current().section_label);
}
fn route_name(app: &SoundApp, r: &Route) -> String {
@@ -97,14 +97,14 @@ fn route_name(app: &SoundApp, r: &Route) -> String {
}
fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>) {
let t = Tokens::DARK;
let t = Tokens::current();
let Some(track) = app.engine.session().track(id).cloned() else { return };
let narrow = app.ui.narrow_mix;
let w = strip_width(narrow);
let h = ui.available_height().max(560.0);
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
let selected = app.engine.session().edit.selected_tracks.contains(&id);
ui.painter().rect_filled(r, 0.0, if selected { Color32::from_rgb(54, 58, 64) } else { t.strip_bg });
ui.painter().rect_filled(r, 0.0, if selected { t.strip_selected } else { t.strip_bg });
let mut y = r.min.y + 4.0;
let inner_w = w - 8.0;
let x0 = r.min.x + 4.0;
@@ -140,8 +140,9 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 16.0), vec2(inner_w - 4.0, 15.0));
let slot = track.mixer.inserts.iter().position(|i| i.as_ref().is_some_and(|i| plugin_info(&i.plugin).is_some_and(|p| p.is_instrument)));
match slot {
_ if track.kind == TrackKind::Instrument => instrument_slot(app, ui, &track, sr),
Some(k) => insert_slot(app, ui, &track, k, sr),
None if matches!(track.kind, TrackKind::Instrument | TrackKind::Midi) => {
None if track.kind == TrackKind::Midi => {
ui.painter().text(sr.center(), Align2::CENTER_CENTER, "MIDI in: all", regular(9.0), t.text_dim);
}
None => {}
@@ -401,21 +402,47 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
ui.painter().line_segment([pos2(r.max.x, r.min.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border));
}
/// An instrument track's INSTRUMENT row: click opens a hosted instrument's plugin window,
/// right-click (or click, when none is set) picks another instrument.
fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
let t = Tokens::current();
let (id, slot) = (track.id, soundcraft_mix::INSTRUMENT_SLOT);
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 };
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)),
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);
let hosted = ins.is_some_and(|i| soundcraft_mix::is_third_party(&i.plugin));
if resp.clicked() && hosted && !app.ui.plugin_windows.contains(&(id, slot)) {
app.ui.plugin_windows.push((id, slot));
}
let menu_resp = if hosted { resp.clone().on_hover_text("Click: open instrument · right-click: change") } else { resp.clone() };
let popup = if hosted { egui::Popup::context_menu(&menu_resp) } else { egui::Popup::menu(&menu_resp) };
popup.show(|ui| instrument_menu(app, ui, id));
}
fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
let t = Tokens::DARK;
let t = Tokens::current();
let id = track.id;
let ins = track.mixer.inserts.get(slot).cloned().flatten();
let resp = ui.interact(r, ui.id().with(("ins", id.0, slot)), Sense::click());
let fill = match &ins {
Some(i) if i.bypass => Color32::from_rgb(70, 56, 30),
Some(_) => Color32::from_rgb(52, 62, 80),
Some(i) if i.bypass => t.insert_bypass,
Some(_) => t.insert_on,
None => 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,
);
let label = ins.as_ref().and_then(|i| plugin_info(&i.plugin)).map_or("", |p| p.short_name);
@@ -457,44 +484,114 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc
}
});
}
// Third-party CLAP plugins (scanned once, cached by soundcraft-clap-host).
if let Some(plugin) = hosted_menus(ui, false)
&& let Err(e) = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin}))
{
app.ui.status = e;
}
}
/// The instrument picker of an instrument track: its instrument's editor, then the built-in,
/// CLAP, VST3 and Audio Units instruments.
pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
let slot = soundcraft_mix::INSTRUMENT_SLOT;
let current = app.engine.session().track(id).and_then(|t| t.instrument.clone());
if let Some(info) = current.as_ref().and_then(|i| plugin_info(&i.plugin)) {
ui.label(egui::RichText::new(info.name).strong());
if let Some(p) = &app.player
&& p.has_editor(id, slot)
{
if p.editor_open(id, slot) {
if ui.button("Close Plugin Editor").clicked() {
p.close_editor(id, slot);
}
} else if ui.button("Open Plugin Editor").clicked()
&& let Err(e) = p.open_editor(id, slot)
{
app.ui.status = format!("{}: {e}", info.name);
}
}
ui.separator();
}
let mut picked = None;
ui.menu_button("Built-in", |ui| {
for p in soundcraft_dsp::plugins().iter().filter(|p| p.is_instrument) {
if ui.button(p.name).clicked() {
picked = Some(p.id.to_string());
}
}
});
if let Some(p) = hosted_menus(ui, true) {
picked = Some(p);
}
if let Some(plugin) = picked
&& let Err(e) = app.run("mix.instrument", json!({"track": id.0, "plugin": plugin}))
{
app.ui.status = e;
}
}
/// A hosted plugin as the plugin menus list it.
struct MenuPlugin {
id: String,
name: String,
vendor: String,
}
/// The CLAP, VST3 and (on macOS) Audio Units submenus, of effects or of instruments; returns the
/// id picked. Each format is scanned the first time its submenu opens, then cached by its host.
fn hosted_menus(ui: &mut Ui, instruments: bool) -> Option<String> {
let mut picked = None;
ui.menu_button("CLAP", |ui| {
let found: Vec<_> = soundcraft_clap_host::scan().into_iter().filter(|d| !d.is_instrument).collect();
if found.is_empty() {
ui.label("No CLAP plugins found");
}
for d in found {
if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() {
let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id}));
}
let list = soundcraft_clap_host::scan().into_iter().filter(|d| d.is_instrument == instruments);
if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No CLAP plugins found") {
picked = Some(p);
}
});
// Third-party VST3 plugins (scanned once, cached by soundcraft-vst3-host).
ui.menu_button("VST3", |ui| {
let found: Vec<_> = soundcraft_vst3_host::scan().into_iter().filter(|d| !d.is_instrument).collect();
if found.is_empty() {
ui.label("No VST3 plugins found");
}
for d in found {
if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() {
let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id}));
}
let list = soundcraft_vst3_host::scan().into_iter().filter(|d| d.is_instrument == instruments);
if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No VST3 plugins found") {
picked = Some(p);
}
});
// Audio Units (macOS; scanned once, cached by soundcraft-au-host).
if cfg!(target_os = "macos") {
ui.menu_button("Audio Units", |ui| {
let found: Vec<_> = soundcraft_au_host::scan().into_iter().filter(|d| !d.is_instrument).collect();
if found.is_empty() {
ui.label("No Audio Units found");
}
for d in found {
if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() {
let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id}));
}
let list = soundcraft_au_host::scan().into_iter().filter(|d| d.is_instrument == instruments);
if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No Audio Units found")
{
picked = Some(p);
}
});
}
picked
}
/// One submenu per vendor (as Pro Tools lists plug-ins by manufacturer), each list scrolling
/// within the screen so that every plugin stays reachable however many are installed.
fn vendor_menus(ui: &mut Ui, mut list: Vec<MenuPlugin>, none: &str) -> Option<String> {
if list.is_empty() {
ui.label(none);
return None;
}
list.sort_by_cached_key(|p| (p.vendor.to_lowercase(), p.name.to_lowercase()));
let mut vendors: Vec<&str> = list.iter().map(|p| p.vendor.as_str()).collect();
vendors.dedup();
let max_h = (ui.ctx().content_rect().height() - 80.0).max(160.0);
let mut picked = None;
egui::ScrollArea::vertical().id_salt("vendors").max_height(max_h).show(ui, |ui| {
for v in vendors {
ui.menu_button(if v.is_empty() { "Unknown vendor" } else { v }, |ui| {
egui::ScrollArea::vertical().id_salt("plugins").max_height(max_h).show(ui, |ui| {
for p in list.iter().filter(|p| p.vendor == v) {
if ui.button(&p.name).clicked() {
picked = Some(p.id.clone());
}
}
});
});
}
});
picked
}
/// A built-in plugin's description, else a hosted CLAP (`clap:<id>`), VST3 (`vst3:<class id>`) or
@@ -507,12 +604,12 @@ pub fn plugin_info(id: &str) -> Option<&'static soundcraft_dsp::PluginInfo> {
}
fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
let t = Tokens::DARK;
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() { Color32::from_rgb(46, 66, 56) } else { t.slot_bg };
ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, Color32::from_rgb(16, 16, 16)), StrokeKind::Inside);
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);
match &snd {
Some(s) => {
let name = route_name(app, &s.target);
+2 -2
View File
@@ -66,10 +66,10 @@ fn result_line(ui: &mut egui::Ui, r: &Result<Value, String>) {
match r {
Ok(v) => {
let s = v.to_string();
ui.label(egui::RichText::new(if s.len() > 160 { format!("{}…", &s[..160]) } else { s }).small().color(Tokens::DARK.counter_text));
ui.label(egui::RichText::new(if s.len() > 160 { format!("{}…", &s[..160]) } else { s }).small().color(Tokens::current().counter_text));
}
Err(e) => {
ui.label(egui::RichText::new(e).small().color(Tokens::DARK.rec));
ui.label(egui::RichText::new(e).small().color(Tokens::current().rec));
}
}
}
+3 -2
View File
@@ -62,7 +62,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
if !app.ui.show_search {
return;
}
let t = Tokens::DARK;
let t = Tokens::current();
let mut run: Option<String> = None;
let mut close = ctx.input(|i| i.key_pressed(Key::Escape));
egui::Window::new("Search")
@@ -95,7 +95,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let l = ui.selectable_label(sel, &e.label);
ui.label(egui::RichText::new(&e.path).small().color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(&e.shortcut).font(mono(10.0)).color(t.text_dim));
let sc = crate::shortcuts::shortcut_label(&e.shortcut, ctx.os().is_mac());
ui.label(egui::RichText::new(sc).font(mono(10.0)).color(t.text_dim));
});
l
});
+34 -25
View File
@@ -7,7 +7,7 @@ use serde_json::json;
use soundcraft_time::format_position;
fn panel_header(ui: &mut Ui, title: &str) {
let t = Tokens::DARK;
let t = Tokens::current();
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
ui.painter().rect_filled(r, 0.0, t.panel_bg2);
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, title, bold(11.5), t.header_text);
@@ -15,7 +15,7 @@ fn panel_header(ui: &mut Ui, title: &str) {
}
pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let total = ui.available_height();
panel_header(ui, "TRACKS");
let tracks: Vec<(u64, String, bool, [u8; 3], bool)> = {
@@ -26,16 +26,11 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
for (id, name, shown, color, sel) in tracks {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click());
if sel {
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(52, 70, 96));
ui.painter().rect_filled(r, 0.0, t.row_selected);
}
let dot = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(8.0, 8.0));
let dresp = ui.interact(dot.expand(3.0), ui.id().with(("vis", id)), Sense::click());
ui.painter().circle(
dot.center(),
3.5,
if shown { Color32::from_rgb(200, 200, 200) } else { Color32::TRANSPARENT },
Stroke::new(1.0, t.text_dim),
);
ui.painter().circle(dot.center(), 3.5, if shown { t.visible_dot } else { Color32::TRANSPARENT }, Stroke::new(1.0, t.text_dim));
if dresp.clicked() {
let _ = app.run("track.hide", json!({"tracks": [id], "hidden": shown}));
}
@@ -103,7 +98,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
}
pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
panel_header(ui, "CLIPS");
let items: Vec<(Option<u64>, String, bool, [u8; 3])> = {
let s = app.engine.session();
@@ -150,7 +145,7 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
}
}
if !whole && id.is_some_and(|i| selected.contains(&i)) {
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(52, 70, 96));
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));
ui.painter().with_clip_rect(r).text(
@@ -186,7 +181,7 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) {
if !open {
return;
}
let t = Tokens::DARK;
let t = Tokens::current();
egui::Window::new("Transport").open(&mut open).resizable(false).default_pos(pos2(400.0, 500.0)).show(ctx, |ui| {
let flags = app.engine.session().edit.flags.clone();
let expanded = flags.contains("view.transport.expanded");
@@ -275,8 +270,9 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
let s = app.engine.session();
let txt = format_position(app.position(), s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
let r = ui.available_rect_before_wrap();
ui.painter().rect_filled(r, 4.0, Color32::BLACK);
ui.painter().text(r.center(), Align2::CENTER_CENTER, txt, mono((r.height() * 0.6).clamp(20.0, 120.0)), Tokens::DARK.counter_text);
let t = Tokens::current();
ui.painter().rect_filled(r, 4.0, t.big_counter_bg);
ui.painter().text(r.center(), Align2::CENTER_CENTER, txt, mono((r.height() * 0.6).clamp(20.0, 120.0)), t.counter_text);
});
app.ui.show_big_counter = open;
}
@@ -362,7 +358,7 @@ fn undo_history(app: &mut SoundApp, ctx: &egui::Context) {
}
}
if let Some(r) = app.engine.redo_label() {
ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::DARK.text_dim));
ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim));
}
});
});
@@ -373,26 +369,33 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
let wins = app.ui.plugin_windows.clone();
let mut keep = Vec::new();
for (tid, slot) in wins {
let Some((tname, ins)) =
app.engine.session().track(tid).and_then(|t| t.mixer.inserts.get(slot).cloned().flatten().map(|i| (t.name.clone(), i)))
else {
// The instrument slot (an instrument track's instrument) or an insert slot.
let instrument = slot == soundcraft_mix::INSTRUMENT_SLOT;
let Some((tname, ins)) = app.engine.session().track(tid).and_then(|t| {
let ins = if instrument { t.instrument.clone() } else { t.mixer.inserts.get(slot).cloned().flatten() };
ins.map(|i| (t.name.clone(), i))
}) else {
continue;
};
let Some(info) = crate::mix_window::plugin_info(&ins.plugin) else { continue };
let place = if instrument { "instrument".to_string() } else { char::from(b'a'.saturating_add(slot as u8)).to_string() };
let mut open = true;
egui::Window::new(format!("{tname} · {} · {}", (b'a' + slot as u8) as char, info.name))
egui::Window::new(format!("{tname} · {place} · {}", info.name))
.id(egui::Id::new(("plugin", tid.0, slot)))
.open(&mut open)
.default_width(340.0)
.default_pos(egui::pos2(ctx.content_rect().width() - 380.0, 120.0))
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&ins.preset).color(Tokens::DARK.text_dim));
let label = if ins.bypass { "BYPASSED" } else { "Bypass" };
if ui.button(label).clicked() {
let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot}));
// Bypass and presets act on insert slots.
if !instrument {
ui.label(egui::RichText::new(&ins.preset).color(Tokens::current().text_dim));
let label = if ins.bypass { "BYPASSED" } else { "Bypass" };
if ui.button(label).clicked() {
let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot}));
}
ui.menu_button("Presets ▾", |ui| preset_menu(app, ui, tid, slot, info, &ins));
}
ui.menu_button("Presets ▾", |ui| preset_menu(app, ui, tid, slot, info, &ins));
if soundcraft_mix::is_third_party(&ins.plugin) {
let open = app.player.as_ref().is_some_and(|p| p.editor_open(tid, slot));
let available = app.player.as_ref().is_some_and(|p| p.has_editor(tid, slot));
@@ -411,6 +414,12 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
}
}
});
if instrument {
// The parameter commands address insert slots: an instrument is played and
// edited in its own editor.
ui.label(egui::RichText::new("Load and edit sounds in the plugin's own editor.").color(Tokens::current().text_dim));
return;
}
if info.id == "eq_7band" || info.id == "eq_1band" {
eq_curve(ui, info.id, &ins);
}
@@ -462,7 +471,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
}
fn eq_curve(ui: &mut Ui, id: &str, ins: &soundcraft_model::Insert) {
let t = Tokens::DARK;
let t = Tokens::current();
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().max(300.0), 110.0), Sense::hover());
ui.painter().rect_filled(r, 4.0, Color32::from_rgb(14, 18, 22));
let n = 160;
+88 -7
View File
@@ -41,8 +41,17 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
key.map(|k| (m, k))
}
fn mods_match(want: Modifiers, got: Modifiers) -> bool {
want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd)
/// Whether the held modifiers `got` fire a shortcut that asks for `want`.
fn mods_match(want: Modifiers, got: Modifiers, mac: bool) -> bool {
// Off macOS egui reports Ctrl as both `ctrl` and `command`: Ctrl is the shortcut's Cmd, and a shortcut's
// Ctrl (the Mac Control key) has no key of its own there, so Ctrl+X is Cut, not Cmd+Ctrl+X.
let ctrl = if mac { want.ctrl == got.ctrl } else { !want.ctrl };
want.command == got.command && want.shift == got.shift && want.alt == got.alt && ctrl
}
/// Cmd+Ctrl+S opens Search. Off macOS Ctrl+S is Save (Cmd+S), so Search is reached from the Window menu.
fn search_chord(m: Modifiers, mac: bool) -> bool {
mac && m.command && m.ctrl
}
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
@@ -58,12 +67,14 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
})
.collect()
});
// Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees).
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
for (key, mods) in events {
if fixed(app, key, mods) {
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)));
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 crate::menus::invoke_shortcut_dialog(app, id) {
@@ -75,7 +86,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
}
/// Keys with context-dependent meaning. Returns true when handled.
fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool {
fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
match key {
Key::Space if !m.command && !m.alt => {
@@ -87,7 +98,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool {
let _ = app.run("transport.record", json!({}));
true
}
Key::S if m.command && m.ctrl => {
Key::S if search_chord(m, mac) => {
let _ = app.run("window.search", json!({}));
true
}
@@ -154,7 +165,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool {
}
true
}
Key::ArrowUp | Key::ArrowDown if m.ctrl => {
Key::ArrowUp | Key::ArrowDown if m.ctrl && !m.shift => {
let id = if key == Key::ArrowUp { "edit.extend_selection_up" } else { "edit.extend_selection_down" };
let _ = app.run(id, json!({}));
true
@@ -227,3 +238,73 @@ fn tab_clip(app: &mut SoundApp, back: bool) {
let _ = app.run("transport.locate", json!({"at": n}));
}
}
/// Shortcut text for display. The registry stores macOS-style strings ("Cmd+N"); off macOS the Cmd
/// modifier is the Ctrl key. Mac-only Control chords ("Cmd+Ctrl+S") have no key there, so they show nothing.
pub fn shortcut_label(s: &str, mac: bool) -> String {
if mac || !s.split('+').any(|p| p == "Cmd") {
return s.to_string();
}
if s.split('+').any(|p| p == "Ctrl") {
return String::new();
}
s.split('+').map(|p| if p == "Cmd" { "Ctrl" } else { p }).collect::<Vec<_>>().join("+")
}
#[cfg(test)]
mod label_tests {
use super::shortcut_label;
#[test]
fn cmd_becomes_ctrl_off_mac() {
assert_eq!(shortcut_label("Cmd+N", false), "Ctrl+N");
assert_eq!(shortcut_label("Cmd+Shift+W", false), "Ctrl+Shift+W");
assert_eq!(shortcut_label("Cmd+Alt+B", false), "Ctrl+Alt+B");
assert_eq!(shortcut_label("Cmd+,", false), "Ctrl+,");
assert_eq!(shortcut_label("Shift+R", false), "Shift+R");
assert_eq!(shortcut_label("Cmd+Ctrl+S", false), "");
assert_eq!(shortcut_label("", false), "");
}
#[test]
fn unchanged_on_mac() {
for s in ["Cmd+N", "Cmd+Shift+W", "Cmd+Alt+B", "Shift+R", "Cmd+Ctrl+S"] {
assert_eq!(shortcut_label(s, true), s);
}
}
}
#[cfg(test)]
mod tests {
use super::{mods_match, parse, search_chord};
use egui::Modifiers;
/// Ctrl held on Windows or Linux, as egui reports it.
const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
/// Cmd held on macOS.
const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
fn want(s: &str) -> Modifiers {
parse(s).map(|(m, _)| m).unwrap()
}
#[test]
fn ctrl_is_cmd_off_macos() {
assert!(mods_match(want("Cmd+S"), PC_CTRL, false));
assert!(mods_match(want("Cmd+Shift+N"), Modifiers { shift: true, ..PC_CTRL }, false));
assert!(mods_match(want("Cmd+X"), PC_CTRL, false));
assert!(!mods_match(want("Cmd+Ctrl+X"), PC_CTRL, false));
assert!(!mods_match(want("Cmd+S"), Modifiers::NONE, false));
assert!(!search_chord(PC_CTRL, false));
}
#[test]
fn cmd_and_ctrl_stay_apart_on_macos() {
assert!(mods_match(want("Cmd+S"), MAC_CMD, true));
assert!(!mods_match(want("Cmd+S"), Modifiers::CTRL, true));
assert!(mods_match(want("Cmd+Ctrl+X"), Modifiers { ctrl: true, ..MAC_CMD }, true));
assert!(!mods_match(want("Cmd+X"), Modifiers { ctrl: true, ..MAC_CMD }, true), "Cmd+Ctrl+X is not Cut");
assert!(!search_chord(MAC_CMD, true));
assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true));
}
}
+311 -6
View File
@@ -1,6 +1,52 @@
//! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours.
use egui::{Color32, FontFamily, FontId};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
/// Whether the Light palette is active. Dark is the default (the incumbent is dark-only).
static LIGHT_THEME: AtomicBool = AtomicBool::new(false);
/// The operating system's appearance, asked once off the UI thread.
static SYSTEM: AtomicU8 = AtomicU8::new(SYSTEM_UNKNOWN);
const SYSTEM_UNKNOWN: u8 = 0;
const SYSTEM_ASKING: u8 = 1;
const SYSTEM_DARK: u8 = 2;
const SYSTEM_LIGHT: u8 = 3;
/// 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 {
System,
Light,
#[default]
Dark,
}
impl ThemeMode {
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",
}
}
/// The id used in `ui.theme {"mode": …}` and in the saved UI prefs.
pub const fn id(self) -> &'static str {
match self {
Self::System => "system",
Self::Light => "light",
Self::Dark => "dark",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s.trim()))
}
}
#[derive(Clone, Copy, Debug)]
pub struct Tokens {
@@ -58,6 +104,58 @@ pub struct Tokens {
pub strip_section: Color32,
pub slot_bg: Color32,
pub automation_line: Color32,
/// Menu bar background.
pub menu_bar: Color32,
/// Outline of toolbar groups.
pub group_border: Color32,
/// Edit-mode cell, not selected.
pub mode_off: Color32,
/// Counter-panel toggle (Grid, Count Off), off.
pub counter_off: Color32,
/// Outline of the counter panels.
pub counter_border: Color32,
/// Divider inside the counter panels.
pub counter_rule: Color32,
/// Smart-tool bracket, off.
pub tool_bracket_off: Color32,
/// Selected row in the Tracks, Groups and Clips lists.
pub row_selected: Color32,
/// Track-visible dot in the Tracks list.
pub visible_dot: Color32,
/// Big Counter background.
pub big_counter_bg: Color32,
/// Edit-window track header, selected.
pub header_selected: Color32,
/// Mix-window strip, selected.
pub strip_selected: Color32,
/// Marker names on the marker ruler.
pub marker_text: Color32,
/// Alternate-playlist lanes.
pub playlist_lane: Color32,
/// Universe overview background.
pub universe_bg: Color32,
/// Universe visible-area outline.
pub universe_view: Color32,
/// Meter background.
pub meter_bg: Color32,
/// Meter clip indicator, not clipped.
pub meter_clip_off: Color32,
/// Pan knob body.
pub knob_bg: Color32,
/// Pan knob outline.
pub knob_ring: Color32,
/// Pan knob pointer.
pub knob_pointer: Color32,
/// Mix insert slot holding a plugin.
pub insert_on: Color32,
/// Mix insert slot, bypassed.
pub insert_bypass: Color32,
/// Mix send slot in use.
pub send_on: Color32,
/// Mix insert/send slot outline.
pub slot_border: Color32,
/// Mix-window section captions (INSERTS A-E, I/O…).
pub section_label: Color32,
}
impl Tokens {
@@ -116,19 +214,185 @@ impl Tokens {
strip_section: Color32::from_rgb(36, 36, 38),
slot_bg: Color32::from_rgb(28, 28, 29),
automation_line: Color32::from_rgb(240, 240, 240),
menu_bar: Color32::from_rgb(22, 22, 23),
group_border: Color32::from_rgb(8, 8, 8),
mode_off: Color32::from_rgb(20, 36, 22),
counter_off: Color32::from_rgb(30, 50, 32),
counter_border: Color32::from_rgb(50, 50, 50),
counter_rule: Color32::from_rgb(40, 40, 40),
tool_bracket_off: Color32::from_rgb(64, 64, 66),
row_selected: Color32::from_rgb(52, 70, 96),
visible_dot: Color32::from_rgb(200, 200, 200),
big_counter_bg: Color32::from_rgb(0, 0, 0),
header_selected: Color32::from_rgb(52, 58, 66),
strip_selected: Color32::from_rgb(54, 58, 64),
marker_text: Color32::from_rgb(236, 236, 236),
playlist_lane: Color32::from_rgb(30, 30, 31),
universe_bg: Color32::from_rgb(18, 18, 19),
universe_view: Color32::from_rgb(255, 255, 255),
meter_bg: Color32::from_rgb(8, 8, 8),
meter_clip_off: Color32::from_rgb(60, 20, 20),
knob_bg: Color32::from_rgb(26, 26, 28),
knob_ring: Color32::from_rgb(90, 90, 94),
knob_pointer: Color32::from_rgb(230, 230, 230),
insert_on: Color32::from_rgb(52, 62, 80),
insert_bypass: Color32::from_rgb(70, 56, 30),
send_on: Color32::from_rgb(46, 66, 56),
slot_border: Color32::from_rgb(16, 16, 16),
section_label: Color32::from_rgb(200, 200, 200),
};
pub fn get(_ctx: &egui::Context) -> Tokens {
Tokens::DARK
pub const LIGHT: Tokens = Tokens {
window_bg: Color32::from_rgb(238, 239, 242),
panel_bg: Color32::from_rgb(248, 249, 251),
panel_bg2: Color32::from_rgb(255, 255, 255),
toolbar_bg: Color32::from_rgb(230, 232, 236),
toolbar_group: Color32::from_rgb(242, 243, 246),
border: Color32::from_rgb(190, 193, 200),
border_light: Color32::from_rgb(210, 213, 220),
text: Color32::from_rgb(35, 36, 40),
text_dim: Color32::from_rgb(95, 98, 106),
text_dark: Color32::from_rgb(20, 21, 24),
header_text: Color32::from_rgb(45, 47, 53),
counter_bg: Color32::from_rgb(224, 226, 231),
counter_text: Color32::from_rgb(22, 116, 45),
counter_label: Color32::from_rgb(65, 68, 75),
accent: Color32::from_rgb(62, 116, 180),
accent_dark: Color32::from_rgb(43, 88, 145),
mode_on: Color32::from_rgb(75, 158, 88),
mode_on_text: Color32::from_rgb(255, 255, 255),
mode_off_text: Color32::from_rgb(48, 122, 62),
button: Color32::from_rgb(224, 226, 230),
button_hi: Color32::from_rgb(210, 213, 219),
button_border: Color32::from_rgb(175, 179, 187),
name_field: Color32::from_rgb(255, 255, 255),
name_field_sel: Color32::from_rgb(236, 242, 252),
ruler_bg: Color32::from_rgb(226, 228, 233),
ruler_label_bg: Color32::from_rgb(226, 228, 233),
ruler_text: Color32::from_rgb(70, 73, 80),
ruler_tick: Color32::from_rgb(130, 134, 142),
tempo_ruler: Color32::from_rgb(104, 170, 124),
meter_ruler: Color32::from_rgb(92, 158, 184),
marker_ruler: Color32::from_rgb(210, 213, 219),
playlist_bg: Color32::from_rgb(246, 247, 249),
playlist_alt: Color32::from_rgb(238, 240, 244),
grid_line: Color32::from_rgb(210, 213, 219),
bar_line: Color32::from_rgb(185, 189, 197),
selection: Color32::from_rgba_premultiplied(100, 145, 205, 90),
playhead: Color32::from_rgb(210, 48, 42),
insertion: Color32::from_rgb(35, 36, 40),
solo: Color32::from_rgb(178, 145, 20),
mute: Color32::from_rgb(194, 106, 22),
rec: Color32::from_rgb(190, 42, 38),
input: Color32::from_rgb(45, 126, 64),
auto_read: Color32::from_rgb(52, 142, 68),
auto_write: Color32::from_rgb(190, 50, 45),
auto_touch: Color32::from_rgb(180, 122, 24),
auto_latch: Color32::from_rgb(118, 80, 170),
meter_green: Color32::from_rgb(36, 160, 58),
meter_yellow: Color32::from_rgb(190, 162, 20),
meter_red: Color32::from_rgb(195, 42, 35),
fader_track: Color32::from_rgb(198, 201, 207),
strip_bg: Color32::from_rgb(235, 237, 241),
strip_section: Color32::from_rgb(244, 245, 247),
slot_bg: Color32::from_rgb(248, 249, 251),
automation_line: Color32::from_rgb(40, 42, 48),
menu_bar: Color32::from_rgb(222, 224, 229),
group_border: Color32::from_rgb(190, 193, 200),
mode_off: Color32::from_rgb(236, 240, 237),
counter_off: Color32::from_rgb(214, 230, 217),
counter_border: Color32::from_rgb(190, 193, 200),
counter_rule: Color32::from_rgb(204, 207, 213),
tool_bracket_off: Color32::from_rgb(200, 203, 210),
row_selected: Color32::from_rgb(198, 216, 240),
visible_dot: Color32::from_rgb(110, 113, 120),
big_counter_bg: Color32::from_rgb(224, 226, 231),
header_selected: Color32::from_rgb(206, 219, 238),
strip_selected: Color32::from_rgb(206, 219, 238),
marker_text: Color32::from_rgb(35, 36, 40),
playlist_lane: Color32::from_rgb(232, 234, 238),
universe_bg: Color32::from_rgb(228, 230, 234),
universe_view: Color32::from_rgb(40, 42, 48),
meter_bg: Color32::from_rgb(196, 199, 206),
meter_clip_off: Color32::from_rgb(232, 200, 198),
knob_bg: Color32::from_rgb(252, 252, 253),
knob_ring: Color32::from_rgb(150, 154, 162),
knob_pointer: Color32::from_rgb(40, 42, 48),
insert_on: Color32::from_rgb(206, 218, 236),
insert_bypass: Color32::from_rgb(240, 222, 186),
send_on: Color32::from_rgb(204, 230, 214),
slot_border: Color32::from_rgb(175, 179, 187),
section_label: Color32::from_rgb(95, 98, 106),
};
pub fn current() -> Tokens {
if is_light() { Self::LIGHT } else { Self::DARK }
}
}
/// Whether the Light palette is the active one.
pub fn is_light() -> bool {
LIGHT_THEME.load(Ordering::Relaxed)
}
/// Whether `mode` resolves to the Light palette right now. Never blocks.
pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool {
match mode {
ThemeMode::Light => true,
ThemeMode::Dark => false,
ThemeMode::System => system_is_light(ctx),
}
}
/// Forget the cached system appearance so the next System lookup asks again.
pub fn refresh_system() {
let _ = SYSTEM.compare_exchange(SYSTEM_DARK, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed);
let _ = SYSTEM.compare_exchange(SYSTEM_LIGHT, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed);
}
/// The OS appearance. The query (a D-Bus call on Linux) runs once on a background thread; until it
/// answers this reports dark, and `ctx` is repainted when the answer arrives.
#[cfg(not(target_arch = "wasm32"))]
fn system_is_light(ctx: &egui::Context) -> bool {
if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
let ctx = ctx.clone();
let spawned = std::thread::Builder::new().name("soundcraft-theme".into()).spawn(move || {
let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light));
SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed);
ctx.request_repaint();
});
if spawned.is_err() {
SYSTEM.store(SYSTEM_DARK, Ordering::Relaxed);
}
}
SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT
}
/// On the web the query is a synchronous `matchMedia` lookup (no threads there).
#[cfg(target_arch = "wasm32")]
fn system_is_light(_ctx: &egui::Context) -> bool {
if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light));
SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed);
}
SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT
}
/// Track colours tinted for clip bodies.
pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) {
let [r, g, b] = rgb;
let k = if selected { 0.95 } else { 0.62 };
let body = Color32::from_rgb((f32::from(r) * k) as u8, (f32::from(g) * k) as u8, (f32::from(b) * k) as u8);
let bar = Color32::from_rgb((f32::from(r) * 0.9) as u8, (f32::from(g) * 0.9) as u8, (f32::from(b) * 0.9) as u8);
if is_light() {
// A paler body and a waveform in a deep shade of the clip's own colour.
let tint = |c: u8, k: f32| (f32::from(c) + (255.0 - f32::from(c)) * k) as u8;
let k = if selected { 0.2 } else { 0.45 };
let body = Color32::from_rgb(tint(r, k), tint(g, k), tint(b, k));
let d = if selected { 0.22 } else { 0.32 };
let wave = Color32::from_rgb((f32::from(r) * d) as u8, (f32::from(g) * d) as u8, (f32::from(b) * d) as u8);
return (body, bar, wave);
}
let wave = if selected { Color32::from_rgb(20, 20, 24) } else { Color32::from_rgb(12, 12, 14) };
(body, bar, wave)
}
@@ -149,10 +413,11 @@ pub fn mono(size: f32) -> FontId {
FontId::new(size, FontFamily::Monospace)
}
/// Apply visuals to the context.
pub fn apply(ctx: &egui::Context) {
let t = Tokens::DARK;
let mut v = egui::Visuals::dark();
/// 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 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;
@@ -172,3 +437,43 @@ pub fn apply(ctx: &egui::Context) {
s.interaction.tooltip_delay = 0.4;
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Services, SoundApp, UiState};
use serde_json::json;
#[test]
fn dark_is_the_default() {
assert_eq!(ThemeMode::default(), ThemeMode::Dark);
assert_eq!(UiState::default().theme, ThemeMode::Dark);
// Prefs saved before the setting existed load as Dark.
let old: UiState = serde_json::from_value(json!({"show_tracks_list": true})).unwrap_or_default();
assert_eq!(old.theme, ThemeMode::Dark);
}
#[test]
fn modes_round_trip_through_prefs() {
for mode in ThemeMode::ALL {
assert_eq!(ThemeMode::parse(mode.id()), Some(mode));
let ui = UiState { theme: mode, ..UiState::default() };
let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap_or_default()).unwrap_or_default();
assert_eq!(back.theme, mode);
}
assert_eq!(ThemeMode::parse(" Light "), Some(ThemeMode::Light));
assert_eq!(ThemeMode::parse("sepia"), None);
}
#[test]
fn theme_command_sets_and_reports_the_mode() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
assert_eq!(app.run("ui.theme", json!({})), Ok(json!({"mode": "dark"})));
assert_eq!(app.run("ui.theme", json!({"mode": "light"})), Ok(json!({"mode": "light"})));
assert_eq!(app.ui.theme, ThemeMode::Light);
assert!(app.run("ui.theme", json!({"mode": "sepia"})).is_err());
assert_eq!(app.ui.theme, ThemeMode::Light);
assert_eq!(app.run("ui.theme", json!({"mode": "system"})), Ok(json!({"mode": "system"})));
assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "ui.theme"));
}
}
+17 -18
View File
@@ -11,15 +11,15 @@ use soundcraft_time::{TimeFormat, format_length, format_position};
pub const HEIGHT: f32 = 78.0;
fn group(ui: &mut Ui, w: f32, h: f32, add: impl FnOnce(&mut Ui, Rect)) {
let t = Tokens::DARK;
let t = Tokens::current();
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.toolbar_group, Stroke::new(1.0, Color32::from_rgb(8, 8, 8)), StrokeKind::Inside);
ui.painter().rect(r, CornerRadius::same(4), t.toolbar_group, Stroke::new(1.0, t.group_border), StrokeKind::Inside);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(5.0)).layout(egui::Layout::left_to_right(egui::Align::Min)));
add(&mut child, r);
}
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let full = ui.max_rect();
ui.painter().rect_filled(full, 0.0, t.toolbar_bg);
ui.add_space(4.0);
@@ -38,7 +38,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
}
fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let cur = app.engine.session().edit.edit_mode;
group(ui, 112.0, 52.0, |ui, r| {
let inner = r.shrink(6.0);
@@ -54,7 +54,7 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0);
let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click());
let on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative);
ui.painter().rect_filled(cell, 1.0, if on { t.mode_on } else { Color32::from_rgb(20, 36, 22) });
ui.painter().rect_filled(cell, 1.0, if on { t.mode_on } else { t.mode_off });
let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label };
ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), if on { t.mode_on_text } else { t.mode_off_text });
if resp.clicked() {
@@ -114,7 +114,7 @@ fn zoom_group(app: &mut SoundApp, ui: &mut Ui) {
}
fn tools(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let cur = app.engine.session().edit.tool;
group(ui, 268.0 + 38.0, 52.0, |ui, r| {
ui.vertical(|ui| {
@@ -142,7 +142,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) {
let bx1 = bx0 + 38.0 * 3.0 - 2.0;
let bracket = Rect::from_min_max(pos2(bx0 - 2.0, r.min.y + 1.0), pos2(bx1 + 2.0, r.min.y + 5.0));
let resp = ui.interact(bracket.expand2(vec2(0.0, 2.0)), ui.id().with("smart"), Sense::click());
ui.painter().rect_filled(bracket, 1.0, if cur == Tool::Smart { t.accent } else { Color32::from_rgb(64, 64, 66) });
ui.painter().rect_filled(bracket, 1.0, if cur == Tool::Smart { t.accent } else { t.tool_bracket_off });
if resp.on_hover_text("Smart Tool (F6+F7 / F7+F8)").clicked() {
let _ = app.run("edit.tool", json!({"tool": "smart"}));
}
@@ -177,14 +177,14 @@ fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String {
}
fn counters(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let (main_fmt, sub_fmt, sel) = {
let s = app.engine.session();
(s.edit.main_counter, s.edit.sub_counter.unwrap_or(TimeFormat::Samples), s.edit.selection)
};
let pos = app.position();
let (r, _) = ui.allocate_exact_size(vec2(318.0, 64.0), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside);
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside);
let main_r = Rect::from_min_max(r.min, pos2(r.min.x + 180.0, r.min.y + 44.0));
let txt = fmt_pos(app, pos, main_fmt);
ui.painter().text(pos2(main_r.max.x - 18.0, main_r.center().y), Align2::RIGHT_CENTER, txt, mono(26.0), t.counter_text);
@@ -211,8 +211,7 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
}
// Sub counter row.
let y = r.max.y - 11.0;
ui.painter()
.line_segment([pos2(r.min.x + 6.0, r.max.y - 21.0), pos2(r.max.x - 6.0, r.max.y - 21.0)], Stroke::new(1.0, Color32::from_rgb(40, 40, 40)));
ui.painter().line_segment([pos2(r.min.x + 6.0, r.max.y - 21.0), pos2(r.max.x - 6.0, r.max.y - 21.0)], Stroke::new(1.0, t.counter_rule));
ui.painter().text(pos2(r.min.x + 10.0, y), Align2::LEFT_CENTER, "Sub", regular(11.0), t.counter_label);
ui.painter().text(pos2(r.min.x + 150.0, y), Align2::RIGHT_CENTER, fmt_pos(app, pos, sub_fmt), mono(11.0), t.counter_text);
let st = if app.engine.is_dirty() { "modified" } else { "saved" };
@@ -221,15 +220,15 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
}
fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let (grid, nudge, lines) = {
let e = &app.engine.session().edit;
(e.grid, e.nudge, e.grid_lines)
};
let (r, _) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside);
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside);
let gl = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 8.0), vec2(48.0, 16.0));
ui.painter().rect_filled(gl, 1.0, if lines { t.mode_on } else { Color32::from_rgb(30, 50, 32) });
ui.painter().rect_filled(gl, 1.0, if lines { t.mode_on } else { t.counter_off });
ui.painter().text(gl.center(), Align2::CENTER_CENTER, "Grid", bold(11.0), if lines { t.mode_on_text } else { t.mode_off_text });
if ui.interact(gl, ui.id().with("grid_lines"), Sense::click()).on_hover_text("Show grid lines").clicked() {
let _ = app.run("view.grid_lines", json!({}));
@@ -272,7 +271,7 @@ fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str) {
}
fn transport(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let playing = app.is_playing();
let recording = app.engine.transport.recording;
let looped = app.engine.session().edit.loop_playback;
@@ -351,7 +350,7 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) {
}
fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::DARK;
let t = Tokens::current();
let (bpm, meter, countoff, click, bars) = {
let s = app.engine.session();
let tick = s.tempo.samples_to_ticks(app.position(), s.sample_rate);
@@ -359,9 +358,9 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
(s.tempo.tempo_at_tick(tick), format!("{}/{}", m.numerator, m.denominator), s.edit.countoff, s.edit.click, s.edit.countoff_bars)
};
let (r, _) = ui.allocate_exact_size(vec2(150.0, 64.0), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside);
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside);
let co = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 6.0), vec2(64.0, 15.0));
ui.painter().rect_filled(co, 1.0, if countoff { t.mode_on } else { Color32::from_rgb(30, 50, 32) });
ui.painter().rect_filled(co, 1.0, if countoff { t.mode_on } else { t.counter_off });
ui.painter().text(co.center(), Align2::CENTER_CENTER, "Count Off", bold(10.0), if countoff { t.mode_on_text } else { t.mode_off_text });
if ui.interact(co, ui.id().with("countoff"), Sense::click()).clicked() {
let _ = app.run("options.countoff", json!({}));
+41 -14
View File
@@ -6,7 +6,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeK
/// A toolbar button with an icon; `on` draws the blue selected state.
pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &str) -> Response {
let t = Tokens::DARK;
let t = Tokens::current();
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let fill = if on {
t.accent
@@ -24,7 +24,7 @@ pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &st
/// Square text toggle used for S / M / I / record etc.
pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color: Color32, tip: &str) -> Response {
let t = Tokens::DARK;
let t = Tokens::current();
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let fill = if on {
on_color
@@ -41,7 +41,7 @@ pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color
/// Record-enable button (red dot).
pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response {
let t = Tokens::DARK;
let t = Tokens::current();
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let fill = if on {
t.rec
@@ -57,7 +57,7 @@ pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Respons
/// A dropdown-looking selector box. Returns the response (caller opens a menu on click).
pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32) -> Response {
let t = Tokens::DARK;
let t = Tokens::current();
let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click());
ui.painter().rect(
r,
@@ -76,7 +76,7 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col
/// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets.
pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response {
let t = Tokens::DARK;
let t = Tokens::current();
let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag());
if resp.dragged() {
let d = resp.drag_delta();
@@ -87,7 +87,7 @@ pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response
}
let c = r.center();
let rad = size * 0.42;
ui.painter().circle(c, rad, Color32::from_rgb(26, 26, 28), Stroke::new(1.0, Color32::from_rgb(90, 90, 94)));
ui.painter().circle(c, rad, t.knob_bg, Stroke::new(1.0, t.knob_ring));
let n = 24;
let start = std::f32::consts::PI * 0.75;
let sweep = std::f32::consts::PI * 1.5;
@@ -104,7 +104,7 @@ pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response
let (a0, a1) = if at < mid { (at, mid) } else { (mid, at) };
ui.painter().add(egui::Shape::line(arc(a0, a1), Stroke::new(2.0, t.counter_text)));
let tip_pos = c + vec2(at.cos(), at.sin()) * rad * 0.85;
ui.painter().line_segment([c, tip_pos], Stroke::new(2.0, Color32::from_rgb(230, 230, 230)));
ui.painter().line_segment([c, tip_pos], Stroke::new(2.0, t.knob_pointer));
resp.on_hover_text(tip)
}
@@ -120,8 +120,8 @@ pub fn pan_text(v: f32) -> String {
/// Vertical peak meter with green/yellow/red zones; values are linear peaks.
pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) {
let t = Tokens::DARK;
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(8, 8, 8));
let t = Tokens::current();
ui.painter().rect_filled(r, 0.0, t.meter_bg);
let pos = |v: f32| -> f32 {
let db = if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() };
meter_pos(db)
@@ -151,7 +151,7 @@ pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) {
);
}
let clip_r = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + 2.5));
ui.painter().rect_filled(clip_r, 0.0, if clip { t.meter_red } else { Color32::from_rgb(60, 20, 20) });
ui.painter().rect_filled(clip_r, 0.0, if clip { t.meter_red } else { t.meter_clip_off });
}
/// dB → 0..1 meter position (logarithmic-ish scale like hardware meters).
@@ -172,9 +172,20 @@ pub fn meter_pos(db: f32) -> f32 {
}
}
/// Clip gain fader taper: the top three quarters span -36..+36 dB evenly, the rest compresses to -144.
pub fn clip_gain_from_pos(pos: f32) -> f32 {
let p = if pos.is_finite() { pos.clamp(0.0, 1.0) } else { 0.0 };
if p >= 0.25 { -36.0 + (p - 0.25) / 0.75 * 72.0 } else { -36.0 - (1.0 - p / 0.25).powf(1.5) * 108.0 }
}
pub fn clip_gain_to_pos(db: f32) -> f32 {
let db = if db.is_finite() { db.clamp(-144.0, 36.0) } else { -144.0 };
if db >= -36.0 { 0.25 + (db + 36.0) / 72.0 * 0.75 } else { (1.0 - ((-36.0 - db) / 108.0).powf(1.0 / 1.5)) * 0.25 }
}
/// A fader: returns the new dB value when dragged. `db` in -144..12.
pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option<f32> {
let t = Tokens::DARK;
let t = Tokens::current();
let resp = ui.interact(r, id, Sense::click_and_drag());
let slot = Rect::from_center_size(r.center(), vec2(5.0, r.height() - 14.0));
ui.painter().rect_filled(slot, 2.0, t.fader_track);
@@ -237,7 +248,7 @@ pub enum PannerEdit {
/// Drag moves the puck; double-click returns it to front centre; Alt-drag (or the slider below)
/// sets divergence; height layouts get a second slider for elevation. Returns the edit, if any.
pub fn surround_panner(ui: &mut Ui, size: f32, speakers: &[PannerSpeaker], puck: (f32, f32), divergence: f32, z: Option<f32>) -> Option<PannerEdit> {
let t = Tokens::DARK;
let t = Tokens::current();
let mut edit = None;
let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag());
let half = size * 0.5 - 6.0;
@@ -335,12 +346,28 @@ pub fn multi_meter(ui: &Ui, r: Rect, levels: &[f32], labels: &[String], clip: bo
let br = Rect::from_min_size(pos2(x, mr.min.y), vec2(w, mr.height()));
meter(ui, br, *lv, *lv, clip);
if let Some(l) = labels.get(i) {
let galley = ui.painter().layout_no_wrap(l.clone(), regular(7.0), Tokens::DARK.text_dim);
let galley = ui.painter().layout_no_wrap(l.clone(), regular(7.0), Tokens::current().text_dim);
let gw = galley.size().x;
let gh = galley.size().y;
// Rotated 90° anticlockwise, reading bottom-to-top, centred under the bar.
let pos = pos2(br.center().x - gh * 0.5, mr.max.y + 2.0 + gw.min(label_h - 2.0));
ui.painter().add(egui::epaint::TextShape::new(pos, galley, Tokens::DARK.text_dim).with_angle(-std::f32::consts::FRAC_PI_2));
ui.painter().add(egui::epaint::TextShape::new(pos, galley, Tokens::current().text_dim).with_angle(-std::f32::consts::FRAC_PI_2));
}
}
}
#[cfg(test)]
mod tests {
use super::{clip_gain_from_pos, clip_gain_to_pos};
#[test]
fn clip_gain_taper_spans_minus_144_to_plus_36_and_round_trips() {
assert_eq!(clip_gain_from_pos(0.0), -144.0);
assert_eq!(clip_gain_from_pos(1.0), 36.0);
assert_eq!(clip_gain_from_pos(f32::NAN), -144.0);
assert_eq!(clip_gain_to_pos(f32::INFINITY), 0.0);
for db in [-144.0, -90.0, -36.0, -12.5, 0.0, 6.0, 36.0] {
assert!((clip_gain_from_pos(clip_gain_to_pos(db)) - db).abs() < 0.01, "{db}");
}
}
}
+87 -2
View File
@@ -7,6 +7,7 @@ use serde_json::json;
use soundcraft_model::{AutomationMode, ClipContent};
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
audio_health(app, ctx);
automation(app, ctx);
color_palette(app, ctx);
disk_usage(app, ctx);
@@ -23,6 +24,60 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
clip_effects(app, ctx);
}
fn audio_health(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_audio_health;
win(ctx, &mut open, "Session Audio Health", vec2(620.0, 360.0), |ui| {
let refresh = ui.button("Refresh Report").clicked();
if app.audio_health_report.is_none() || refresh {
app.audio_health_report = Some(app.engine.execute("session.audio_health", &json!({})).map_err(|error| error.to_string()));
}
let Some(result) = &app.audio_health_report else { return };
match result {
Ok(report) => {
ui.label(format!("{} audio clips checked · {} issues", report["audio_clips"], report["issue_count"]));
ui.label(match report["active_audio_end_seconds"].as_f64() {
Some(seconds) => format!("Active audio ends at {seconds:.2} seconds"),
None => "Active audio end time is unknown (invalid session sample rate)".into(),
});
ui.label("Includes alternate takes and muted clips. Refresh after editing.");
ui.separator();
if report["healthy"] == true {
ui.label("All audio clips have loaded media within source bounds.");
}
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
if let Some(issues) = report["issues"].as_array() {
for issue in issues {
ui.group(|ui| {
let text = |key: &str| issue[key].as_str().unwrap_or("");
ui.label(format!(
"{} / {} / {}{}",
text("track"),
text("playlist"),
text("clip"),
if issue["active"] == true { "" } else { " (alternate playlist)" }
));
ui.label(text("message"));
if let Some(path) = issue["path"].as_str() {
ui.label(path);
}
});
}
}
});
ui.separator();
ui.label("Checks loaded media only, not files on disk, signal levels, plugins or routing.");
}
Err(error) => {
ui.label(error.to_string());
}
}
});
app.ui.show_audio_health = open;
if !open {
app.audio_health_report = None;
}
}
fn win(ctx: &egui::Context, open: &mut bool, title: &str, size: egui::Vec2, body: impl FnOnce(&mut egui::Ui)) {
if !*open {
return;
@@ -125,7 +180,9 @@ fn task_manager(app: &mut SoundApp, ctx: &egui::Context) {
win(ctx, &mut open, "Task Manager", vec2(320.0, 120.0), |ui| {
ui.label("No background tasks are running.");
ui.label(
egui::RichText::new("Renders, bounces and AudioSuite processes run to completion before returning.").small().color(Tokens::DARK.text_dim),
egui::RichText::new("Renders, bounces and AudioSuite processes run to completion before returning.")
.small()
.color(Tokens::current().text_dim),
);
});
app.ui.show_task_manager = open;
@@ -327,6 +384,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
&& let Ok(mut st) = serde_json::from_value::<crate::UiState>(v.clone())
{
st.configurations = app.ui.configurations.clone();
st.theme = app.ui.theme;
st.show_configurations = true;
app.ui = st;
}
@@ -426,11 +484,12 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
/// Setup › Keyboard Shortcuts.
pub fn shortcuts_window(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_shortcuts;
let mac = ctx.os().is_mac();
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(c.shortcut.unwrap_or("")).font(mono(11.0)));
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());
ui.end_row();
@@ -506,3 +565,29 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) {
});
app.ui.show_clip_effects = open;
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod audio_health_tests {
use super::*;
#[test]
fn window_command_renders_report_and_close_clears_snapshot() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
app.run("window.audio_health", json!({"value": true})).unwrap();
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| audio_health(&mut app, ui.ctx()));
output.textures_delta.clear();
assert_eq!(app.audio_health_report.as_ref().unwrap().as_ref().unwrap()["healthy"], true);
assert!(!app.engine.is_dirty());
app.run("window.hide_floating", json!({})).unwrap();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| audio_health(&mut app, ui.ctx()));
output.textures_delta.clear();
assert!(!app.ui.show_audio_health);
assert!(app.audio_health_report.is_none());
app.run("window.audio_health", json!({"value": true})).unwrap();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| audio_health(&mut app, ui.ctx()));
output.textures_delta.clear();
assert!(app.audio_health_report.is_some());
}
}
+53
View File
@@ -0,0 +1,53 @@
use egui::{Event, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, edit_window, widgets};
fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) {
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
})
.textures_delta
.clear();
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
#[test]
fn the_clip_gain_icon_drags_with_the_pointer_in_one_undo_step() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.engine.execute("view.clip_gain_info", &json!({"value": true})).unwrap();
let ctx = egui::Context::default();
frame(&ctx, &mut app, vec![]);
frame(&ctx, &mut app, vec![]);
let s = app.engine.session().clone();
let kick = s.track_by_name("Kick").unwrap();
let clip = kick.clips()[0].clone();
let [x0, y0, x1, y1] = app.edit_layout.timeline;
let tl = Rect::from_min_max(pos2(x0, y0), pos2(x1, y1));
let row = app.edit_layout.rows.iter().find(|(id, _)| *id == kick.id.0).unwrap().1;
let lane = Rect::from_min_max(pos2(tl.min.x, row[1]), pos2(tl.max.x, row[1] + kick.height.points()));
let at = edit_window::clip_gain_icon(&s, tl, lane, &clip).unwrap().center();
let gain = |app: &SoundApp| app.engine.session().find_clip(clip.id).unwrap().1.gain_db;
let up = |dy: f32| widgets::clip_gain_from_pos(widgets::clip_gain_to_pos(0.0) + dy / edit_window::CLIP_FADER_TRAVEL);
frame(&ctx, &mut app, vec![Event::PointerMoved(at)]);
frame(&ctx, &mut app, vec![button(at, true)]);
for dy in [3.0, 6.0, 10.0, 40.0] {
frame(&ctx, &mut app, vec![Event::PointerMoved(at - vec2(0.0, dy))]);
}
assert!((gain(&app) - up(40.0)).abs() < 0.01, "{} vs {}", gain(&app), up(40.0));
// Past the top end and back: the fader waits at +36 dB until the pointer returns.
for dy in [300.0, 120.0, 20.0] {
frame(&ctx, &mut app, vec![Event::PointerMoved(at - vec2(0.0, dy))]);
}
assert!((gain(&app) - up(20.0)).abs() < 0.01, "{} vs {}", gain(&app), up(20.0));
frame(&ctx, &mut app, vec![button(at - vec2(0.0, 20.0), false)]);
frame(&ctx, &mut app, vec![]);
app.engine.execute("edit.undo", &json!({})).unwrap();
assert_eq!(gain(&app), 0.0);
}
+66
View File
@@ -0,0 +1,66 @@
//! Regression: the clip context menu must stay open while the pointer moves onto it, so its
//! Delete item can be used. It used to vanish once the pointer left the lane, because the lane
//! returned early (no hover position) before the menu was drawn.
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
use egui_kittest::Harness;
use soundcraft_ui_egui::{Services, SoundApp};
/// One UI frame, with queued synthetic pointer input applied first (as the control channel does).
fn step(h: &mut Harness<'_, SoundApp>) {
let mut raw = std::mem::take(h.input_mut());
h.state_mut().raw_input_hook(&mut raw);
*h.input_mut() = raw;
h.step();
}
fn press(h: &mut Harness<'_, SoundApp>, x: f32, y: f32, button: PointerButton) {
let pos = pos2(x, y);
let s = &mut h.state_mut().synthetic;
s.push(Event::PointerMoved(pos));
s.push(Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE });
s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE });
}
fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize {
let s = h.state().engine.session();
s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len())
}
#[test]
fn clip_menu_stays_open_while_moving_onto_delete() {
let engine = soundcraft_engine::demo::demo_engine();
let app = SoundApp::new(engine, None, Services::default());
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..6 {
step(&mut h);
}
let before = kick_clip_count(&h);
assert!(before > 0, "the demo session should have a recorded Kick clip");
// Right-click the Kick clip, then move the pointer into the menu and down to Delete.
press(&mut h, 880.0, 240.0, PointerButton::Secondary);
for _ in 0..10 {
step(&mut h);
}
// Move (no click) into the menu, as a real pointer would, so the lane loses its hover.
for (x, y) in [(900.0, 300.0), (907.0, 442.0)] {
h.state_mut().synthetic.push(Event::PointerMoved(pos2(x, y)));
for _ in 0..10 {
step(&mut h);
}
}
press(&mut h, 907.0, 442.0, PointerButton::Primary);
for _ in 0..15 {
step(&mut h);
}
assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip");
}
+22
View File
@@ -0,0 +1,22 @@
use egui::{Key, Modifiers};
use serde_json::json;
use soundcraft_ui_egui::{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();
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear();
}
#[test]
fn ctrl_shift_arrows_nudge_the_selected_clip_gain() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
let ctx = egui::Context::default();
ctx.set_os(egui::os::OperatingSystem::Mac);
let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE };
press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
press(&ctx, &mut app, Key::ArrowDown, ctrl_shift);
assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0);
}
+16
View File
@@ -27,6 +27,22 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std",
name = "editor_main_thread"
harness = false
[[test]]
name = "module_exit"
harness = false
[[test]]
name = "module_exit_live_instance"
harness = false
[[test]]
name = "module_exit_worker_thread"
harness = false
[[test]]
name = "module_exit_shutdown"
harness = false
# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-clap-host: this crate
# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
+233 -26
View File
@@ -15,11 +15,12 @@
#![allow(clippy::unnecessary_cast)]
use crate::{Vst3Error, scan};
use std::collections::HashMap;
use std::ffi::{c_char, c_void};
use std::ffi::{c_char, c_int, c_void};
use std::mem::ManuallyDrop;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError, TryLockError, mpsc};
use std::time::Duration;
use vst3::Steinberg::Vst::{self as sv};
use vst3::Steinberg::Vst::{
IAudioProcessorTrait, IComponentHandlerTrait, IComponentTrait, IConnectionPointTrait, IEditControllerTrait, IEventListTrait,
@@ -96,19 +97,106 @@ pub(crate) struct RawClass {
/// A loaded VST3 binary and its plugin factory. Loaded binaries are cached for the life of the
/// process (see [`Bundle::load`]): plugin code must never be unmapped while an instance may run,
/// and VST3 modules are not required to support being re-entered after their exit function.
/// and VST3 modules are not required to support being re-entered after their exit function, which
/// therefore runs once, at process exit (see [`exit_modules`]).
pub(crate) struct Bundle {
factory: ComPtr<sb::IPluginFactory>,
// Dropped last: the library must outlive the factory pointer.
_lib: libloading::Library,
/// The module exit matching the entry that ran at load (`bundleExit`, `ExitDll`, `ModuleExit`).
exit: Option<ModuleExit>,
/// The thread that ran the module entry, and so must run the exit.
entered_on: EntryThread,
/// Set when plugin objects were leaked on purpose (see [`EditorLink::kill`]): the module exit
/// must not run while any of its objects are alive.
leaked: AtomicBool,
// Never closed: code stays mapped for the process lifetime, even after the module exit.
_lib: ManuallyDrop<libloading::Library>,
}
fn loaded() -> &'static Mutex<HashMap<PathBuf, Arc<Bundle>>> {
static L: OnceLock<Mutex<HashMap<PathBuf, Arc<Bundle>>>> = OnceLock::new();
L.get_or_init(|| Mutex::new(HashMap::new()))
/// Loaded binaries, oldest first (module exits run newest first, the reverse of loading).
fn loaded() -> &'static Mutex<Vec<(PathBuf, Arc<Bundle>)>> {
static L: OnceLock<Mutex<Vec<(PathBuf, Arc<Bundle>)>>> = OnceLock::new();
L.get_or_init(|| Mutex::new(Vec::new()))
}
/// Set once the module exits have run: loading another module after that is refused.
static MODULES_EXITED: AtomicBool = AtomicBool::new(false);
/// Where a module's entry ran. Its exit must run on the same thread: Qt-based modules tie their
/// application object to it (Native Instruments' Massive X crashes in `bundleExit` on another
/// thread). Modules wanted on the main thread are entered there; any other thread's requests go
/// to the module thread, because the asking thread may be gone by the time the process exits.
#[derive(Clone, Copy, PartialEq, Eq)]
enum EntryThread {
Main,
Modules,
}
/// Whether this is the main thread. Not for exit handlers, where `std::thread::current` panics
/// (its thread-local data is gone by then): compare [`os_thread`] with [`MAIN_OS_THREAD`] there.
fn on_main_thread() -> bool {
std::thread::current().name() == Some("main")
}
/// The OS id of the main thread, recorded when it loads a module (0 until then).
static MAIN_OS_THREAD: AtomicUsize = AtomicUsize::new(0);
/// The OS id of the calling thread, which (unlike `std::thread::current`) exit handlers can use.
fn os_thread() -> usize {
#[cfg(unix)]
{
unsafe extern "C" {
fn pthread_self() -> usize;
}
// SAFETY: `pthread_self` takes no arguments and cannot fail; `pthread_t` is an integer or
// a pointer of pointer size on the Unix systems SoundCraft builds for.
unsafe { pthread_self() }
}
#[cfg(windows)]
{
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetCurrentThreadId() -> u32;
}
// SAFETY: `GetCurrentThreadId` takes no arguments and cannot fail.
unsafe { GetCurrentThreadId() as usize }
}
}
type Job = Box<dyn FnOnce() + Send>;
/// The module thread (started on first use): it runs jobs until the process exits.
fn module_thread() -> Option<&'static mpsc::Sender<Job>> {
static TX: OnceLock<Option<mpsc::Sender<Job>>> = OnceLock::new();
TX.get_or_init(|| {
let (tx, rx) = mpsc::channel::<Job>();
let spawned = std::thread::Builder::new().name("vst3-modules".into()).spawn(move || {
while let Ok(job) = rx.recv() {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
}
});
spawned.ok().map(|_| tx)
})
.as_ref()
}
/// Runs `f` on the module thread and waits for its result (at most `timeout`, when given).
fn on_module_thread<R: Send + 'static>(f: impl FnOnce() -> R + Send + 'static, timeout: Option<Duration>) -> Option<R> {
let (rtx, rrx) = mpsc::sync_channel(1);
module_thread()?
.send(Box::new(move || {
let _ = rtx.send(f());
}))
.ok()?;
match timeout {
Some(t) => rrx.recv_timeout(t).ok(),
None => rrx.recv().ok(),
}
}
type GetFactory = unsafe extern "system" fn() -> *mut sb::IPluginFactory;
/// `bundleExit` (macOS), `ExitDll` (Windows) or `ModuleExit` (Linux/BSD). `extern "system"` is the
/// C ABI everywhere except 32-bit Windows, where it is the `PLUGIN_API` (stdcall) `ExitDll` uses.
type ModuleExit = unsafe extern "system" fn() -> bool;
#[cfg(target_os = "macos")]
mod cf {
@@ -123,7 +211,8 @@ mod cf {
/// Opens the shared library and runs the platform module entry (`bundleEntry` with a
/// `CFBundleRef` on macOS, `InitDll` on Windows, `ModuleEntry` with the `dlopen` handle on Linux).
fn open_library(bundle: &Path, exe: &Path) -> Result<libloading::Library, String> {
/// Also returns the matching module exit, when the entry ran and the module exports one.
fn open_library(bundle: &Path, exe: &Path) -> Result<(libloading::Library, Option<ModuleExit>), String> {
#[cfg(all(unix, not(target_os = "macos")))]
{
use libloading::os::unix::{Library as UnixLib, RTLD_LOCAL, RTLD_NOW};
@@ -136,20 +225,25 @@ fn open_library(bundle: &Path, exe: &Path) -> Result<libloading::Library, String
let raw = lib.into_raw();
// SAFETY: `raw` is the handle we just took out of `lib`; it is rewrapped right away.
let lib = unsafe { UnixLib::from_raw(raw) };
let mut exit = None;
if let Some(f) = entry {
// SAFETY: called once, with the module's own `dlopen` handle, as the spec requires.
if !unsafe { f(raw) } {
return Err("ModuleEntry failed".into());
}
// SAFETY: `ModuleExit` is the VST3-mandated Linux exit with this signature; the
// pointer stays valid because loaded modules are never unmapped.
exit = unsafe { lib.get::<ModuleExit>(b"ModuleExit\0") }.ok().map(|s| *s);
}
let _ = bundle;
Ok(lib.into())
Ok((lib.into(), exit))
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
{
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
// hosting plugins; we only load files found in VST3 plugin folders or named explicitly.
let lib = unsafe { libloading::Library::new(exe) }.map_err(|e| e.to_string())?;
let mut exit: Option<ModuleExit> = None;
#[cfg(windows)]
{
let _ = bundle;
@@ -160,6 +254,9 @@ fn open_library(bundle: &Path, exe: &Path) -> Result<libloading::Library, String
if !unsafe { f() } {
return Err("InitDll failed".into());
}
// SAFETY: `ExitDll` is the VST3-mandated Windows exit with this signature; the
// pointer stays valid because loaded modules are never unmapped.
exit = unsafe { lib.get::<ModuleExit>(b"ExitDll\0") }.ok().map(|s| *s);
}
}
#[cfg(target_os = "macos")]
@@ -198,39 +295,143 @@ fn open_library(bundle: &Path, exe: &Path) -> Result<libloading::Library, String
if !unsafe { f(cfbundle) } {
return Err("bundleEntry failed".into());
}
// SAFETY: `bundleExit`/`BundleExit` is the VST3-mandated macOS exit with this
// signature; the pointer stays valid because loaded modules are never unmapped.
exit = unsafe { lib.get::<ModuleExit>(b"bundleExit\0") }
.or_else(|_| {
// SAFETY: as above (older SDKs export the capitalised name).
unsafe { lib.get::<ModuleExit>(b"BundleExit\0") }
})
.ok()
.map(|s| *s);
}
}
Ok(lib)
Ok((lib, exit))
}
}
/// Runs the module exits at process exit, newest module first, each on the thread that ran its
/// entry. VST3 modules expect their exit before their static destructors run, and some crash in
/// those destructors without it (seen with Steinberg's HALion Sonic, after a plain scan). A module
/// with a live instance or leaked objects keeps running and is not exited, and so is one entered
/// on the main thread when the process exits from another thread. Runs once; later loads are
/// refused.
pub(crate) fn exit_modules() {
if MODULES_EXITED.swap(true, Ordering::AcqRel) {
return;
}
let mut cache = match loaded().try_lock() {
Ok(g) => g,
Err(TryLockError::Poisoned(p)) => p.into_inner(),
// Another thread is loading a module right now: leave every module as it is.
Err(TryLockError::WouldBlock) => return,
};
let main = MAIN_OS_THREAD.load(Ordering::Acquire) == os_thread();
let (mut kept, mut here, mut there) = (Vec::new(), Vec::new(), Vec::new());
while let Some((path, b)) = cache.pop() {
if b.leaked.load(Ordering::Acquire) || (b.entered_on == EntryThread::Main && !main) {
kept.push((path, b));
continue;
}
match Arc::try_unwrap(b) {
Ok(bundle) if bundle.entered_on == EntryThread::Main => here.push(bundle),
Ok(bundle) => there.push(bundle),
Err(b) => kept.push((path, b)),
}
}
kept.reverse();
*cache = kept;
drop(cache);
here.into_iter().for_each(Bundle::exit);
if !there.is_empty() {
// Bounded: a module thread stuck inside a plugin must not hang the process exit.
let _ = on_module_thread(move || there.into_iter().for_each(Bundle::exit), Some(Duration::from_secs(5)));
}
}
/// Registers [`exit_modules`] to run at process exit. Exit handlers run in reverse order of
/// registration, C++ static destructors included, so registering again after each module load
/// puts the exits ahead of the destructors of every module loaded so far.
fn register_exit_hook() {
unsafe extern "C" {
fn atexit(f: extern "C" fn()) -> c_int;
}
extern "C" fn hook() {
// Nothing may unwind out of an exit handler.
let _ = std::panic::catch_unwind(exit_modules);
}
// SAFETY: `atexit` is the C library function with this signature; `hook` is a plain function
// of this binary, so it lives as long as the process.
if unsafe { atexit(hook) } != 0 {
log::warn!("vst3: cannot register the module exit handler");
}
}
impl Bundle {
/// Loads (once per path, then cached for the life of the process) a `.vst3`.
pub fn load(path: &Path) -> Result<Arc<Bundle>, Vst3Error> {
let mut map = loaded().lock().unwrap_or_else(PoisonError::into_inner);
if let Some(b) = map.get(path) {
let mut cache = loaded().lock().unwrap_or_else(PoisonError::into_inner);
if let Some((_, b)) = cache.iter().find(|(p, _)| p == path) {
return Ok(b.clone());
}
let b = Arc::new(Bundle::open(path)?);
map.insert(path.to_path_buf(), b.clone());
let err = |m: &str| Vst3Error::Load(path.display().to_string(), m.to_string());
if MODULES_EXITED.load(Ordering::Acquire) {
return Err(err("plugin hosting has shut down"));
}
let b = if on_main_thread() {
MAIN_OS_THREAD.store(os_thread(), Ordering::Release);
Bundle::open(path, EntryThread::Main)?
} else {
let p = path.to_path_buf();
on_module_thread(move || Bundle::open(&p, EntryThread::Modules), None).ok_or_else(|| err("the module thread is not running"))??
};
let b = Arc::new(b);
cache.push((path.to_path_buf(), b.clone()));
// After every new module, so that the exits run before its static destructors.
register_exit_hook();
Ok(b)
}
fn open(path: &Path) -> Result<Bundle, Vst3Error> {
fn open(path: &Path, entered_on: EntryThread) -> Result<Bundle, Vst3Error> {
let err = |m: String| Vst3Error::Load(path.display().to_string(), m);
let exe = scan::bundle_binary(path).ok_or_else(|| err("no loadable binary".into()))?;
let lib = open_library(path, &exe).map_err(err)?;
let (lib, exit) = open_library(path, &exe).map_err(err)?;
// The module entry ran: balance it before giving up on the module.
let fail = |m: String| {
if let Some(f) = exit {
// SAFETY: balanced with the entry that ran in `open_library`; no plugin object exists.
unsafe { f() };
}
err(m)
};
// SAFETY: `GetPluginFactory` is the VST3-mandated export with this signature.
let get = match unsafe { lib.get::<GetFactory>(b"GetPluginFactory\0") } {
Ok(sym) => *sym,
Err(e) => return Err(err(format!("not a VST3 plugin: {e}"))),
Err(e) => return Err(fail(format!("not a VST3 plugin: {e}"))),
};
// SAFETY: the module is loaded and its entry function (if any) has run.
let raw = unsafe { get() };
// SAFETY: a non-null result is a factory pointer with one reference owned by the caller
// (VST3 contract), which the `ComPtr` takes over.
let factory = unsafe { ComPtr::from_raw(raw) }.ok_or_else(|| err("GetPluginFactory returned null".into()))?;
Ok(Bundle { factory, _lib: lib })
let factory = unsafe { ComPtr::from_raw(raw) }.ok_or_else(|| fail("GetPluginFactory returned null".into()))?;
Ok(Bundle { factory, exit, entered_on, leaked: AtomicBool::new(false), _lib: ManuallyDrop::new(lib) })
}
/// Releases the factory, then runs the module exit, as the VST3 SDK's own host does.
fn exit(self) {
let Bundle { factory, exit, .. } = self;
drop(factory);
if let Some(f) = exit {
// SAFETY: balanced with the entry that ran when the module was loaded; called once
// (`self` is consumed), after every instance is gone (each holds an `Arc` to the
// bundle) and the factory released. The code stays mapped (`_lib` is never closed).
unsafe { f() };
}
}
/// Plugin objects of this module were leaked on purpose: never run its module exit.
pub fn mark_leaked(&self) {
self.leaked.store(true, Ordering::Release);
}
/// The vendor from the factory info (fallback for classes without one).
@@ -330,7 +531,7 @@ impl Bundle {
editor: Arc::new(EditorLink::default()),
component,
host,
_bundle: self.clone(),
bundle: self.clone(),
};
let processor = inst.component.cast::<sv::IAudioProcessor>().ok_or_else(|| err("no IAudioProcessor"))?;
inst.processor = Some(processor);
@@ -894,7 +1095,7 @@ pub(crate) struct Instance {
// and host objects are released, and the bundle (which keeps the code mapped) goes last.
component: ComPtr<sv::IComponent>,
host: ComWrapper<HostContext>,
_bundle: Arc<Bundle>,
bundle: Arc<Bundle>,
}
// SAFETY: an `Instance` is used through `&mut self` only, so calls into the plugin are always
@@ -1260,7 +1461,9 @@ impl Instance {
impl Drop for Instance {
fn drop(&mut self) {
// Close any editor and cut its handles off before the controller goes away.
self.editor.kill();
if self.editor.kill() {
self.bundle.mark_leaked();
}
self.shutdown();
if let Some((a, b)) = self.connections.take() {
// SAFETY: the pair was connected in `instantiate`; disconnect before terminating.
@@ -1515,8 +1718,10 @@ impl EditorLink {
}
/// Called by the instance before it terminates: closes the editor and cuts the handles off.
fn kill(&self) {
/// Returns whether plugin objects had to be leaked.
fn kill(&self) -> bool {
let mut g = self.lock();
let mut leaked = false;
if g.view.is_some() || g.window.is_some() {
if window::on_main_thread() {
EditorLink::teardown(&mut g);
@@ -1525,6 +1730,7 @@ impl EditorLink {
log::warn!("vst3: editor still open while its plugin is destroyed off the main thread");
if let Some(v) = g.view.take() {
std::mem::forget(v);
leaked = true;
}
// `HostWindow` has no destructor: dropping it leaks its retain on purpose.
g.window = None;
@@ -1534,6 +1740,7 @@ impl EditorLink {
}
}
g.controller = None;
leaked
}
}
+13
View File
@@ -135,6 +135,9 @@ mod api {
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
registry::plugin_info(id)
}
pub fn shutdown() {
ffi::exit_modules();
}
}
#[cfg(target_arch = "wasm32")]
@@ -162,6 +165,7 @@ mod api {
pub fn plugin_info(_id: &str) -> Option<&'static PluginInfo> {
None
}
pub fn shutdown() {}
}
/// Every plugin in the standard VST3 folders, `VST3_PATH` and folders added with
@@ -187,6 +191,15 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec<Vst3Descriptor> {
api::scan_paths(dirs)
}
/// Shuts plugin hosting down: runs the module exit of every loaded binary that has no live
/// instance left, each on the thread that loaded it; later loads fail. Call it on the main
/// thread once the plugin instances are gone, before the process exits. Without it the exits
/// run from an exit handler, by which time some plugins have already torn down state they need
/// (HALion Sonic crashes in its module exit after it has been used).
pub fn shutdown() {
api::shutdown()
}
/// The audio module classes in one `.vst3` bundle or file.
pub fn load_bundle(path: &Path) -> Result<Vec<Vst3Descriptor>, Vst3Error> {
api::load_bundle(path)
+6 -1
View File
@@ -9,7 +9,12 @@ fn main() {
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
assert_eq!(p.editor_size(), Ok((320, 240)));
// Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported".
if cfg!(target_os = "macos") {
assert_eq!(p.editor_size(), Ok((320, 240)));
} else {
assert!(p.editor_size().is_err());
}
let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");
assert!(!ed.is_open());
assert_eq!(ed.idle().len(), 1, "the fixture's setup edit");
+149 -14
View File
@@ -11,12 +11,15 @@
//! its edit controller). No audio input, one stereo output, one event input; every output
//! sample is the velocity of the held note (sample-accurate), 0 when none.
//!
//! The module checks the host's module lifecycle the way real plugins depend on it (see "module
//! lifecycle" below) and aborts the process when it is broken.
//!
//! Test code: panics are fine here, the host must survive anything anyway.
#![allow(non_snake_case, clippy::missing_safety_doc)]
use std::ffi::{c_char, c_void};
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::ffi::{c_char, c_int, c_void};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use vst3::Steinberg::Vst::*;
use vst3::Steinberg::*;
use vst3::{Class, ComRef, ComWrapper, uid};
@@ -64,6 +67,7 @@ struct GainProcessor {
/// Normalized gain (plain = 2 × normalized).
gain: AtomicU64,
arrangement: AtomicU64,
_live: Live,
}
impl Class for GainProcessor {
@@ -209,10 +213,12 @@ impl IAudioProcessorTrait for GainProcessor {
struct GainController {
gain: AtomicU64,
freq: AtomicU64,
_live: Live,
}
struct View {
attached: std::sync::atomic::AtomicBool,
_live: Live,
}
impl Class for View {
@@ -396,7 +402,7 @@ impl IEditControllerTrait for GainController {
if name.is_null() || unsafe { std::ffi::CStr::from_ptr(name) }.to_bytes() != b"editor" {
return std::ptr::null_mut();
}
match ComWrapper::new(View { attached: std::sync::atomic::AtomicBool::new(false) }).to_com_ptr::<IPlugView>() {
match ComWrapper::new(View { attached: std::sync::atomic::AtomicBool::new(false), _live: Live::new(&OBJECTS) }).to_com_ptr::<IPlugView>() {
Some(p) => p.into_raw(),
None => std::ptr::null_mut(),
}
@@ -407,6 +413,7 @@ impl IEditControllerTrait for GainController {
struct Synth {
level: AtomicU32,
_live: Live,
}
impl Class for Synth {
@@ -582,7 +589,9 @@ impl IEditControllerTrait for Synth {
// ---- factory -------------------------------------------------------------------------------
struct Factory;
struct Factory {
_live: Live,
}
impl Class for Factory {
type Interfaces = (IPluginFactory2,);
@@ -616,13 +625,24 @@ impl IPluginFactoryTrait for Factory {
kResultOk
}
unsafe fn createInstance(&self, cid: FIDString, iid: FIDString, obj: *mut *mut c_void) -> tresult {
set_up_late_state();
let cid = unsafe { *(cid as *const TUID) };
let unknown = if cid == GAIN_CID {
ComWrapper::new(GainProcessor { gain: AtomicU64::new(0.25f64.to_bits()), arrangement: AtomicU64::new(SpeakerArr::kStereo) }).to_com_ptr::<FUnknown>()
ComWrapper::new(GainProcessor {
gain: AtomicU64::new(0.25f64.to_bits()),
arrangement: AtomicU64::new(SpeakerArr::kStereo),
_live: Live::new(&OBJECTS),
})
.to_com_ptr::<FUnknown>()
} else if cid == GAIN_CTRL_CID {
ComWrapper::new(GainController { gain: AtomicU64::new(0.5f64.to_bits()), freq: AtomicU64::new(0.5f64.to_bits()) }).to_com_ptr::<FUnknown>()
ComWrapper::new(GainController {
gain: AtomicU64::new(0.5f64.to_bits()),
freq: AtomicU64::new(0.5f64.to_bits()),
_live: Live::new(&OBJECTS),
})
.to_com_ptr::<FUnknown>()
} else if cid == SYNTH_CID {
ComWrapper::new(Synth { level: AtomicU32::new(0) }).to_com_ptr::<FUnknown>()
ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
} else {
None
};
@@ -653,45 +673,160 @@ impl IPluginFactory2Trait for Factory {
}
}
// ---- module lifecycle ----------------------------------------------------------------------
//
// The contract real plugins rely on: the host runs the module exit once the factory and every
// plugin object are released, on the thread that ran the module entry, and before the process
// tears down the module's statics. Breaking it ends the process with status 3, much as real
// plugins crash (HALion Sonic segfaults in its static destructors when the module exit never ran,
// and in its module exit when that runs from an exit handler after it was used; Massive X in its
// module exit when that runs on another thread than the entry).
/// Module entries minus module exits (they nest, as in the VST3 SDK).
static ENTERED: AtomicI64 = AtomicI64::new(0);
/// The OS thread that ran the outermost module entry.
static ENTRY_THREAD: AtomicUsize = AtomicUsize::new(0);
/// Factories handed out and not yet released.
static FACTORIES: AtomicI64 = AtomicI64::new(0);
/// Plugin objects alive: components, controllers, views.
static OBJECTS: AtomicI64 = AtomicI64::new(0);
static TEARDOWN_REGISTERED: AtomicBool = AtomicBool::new(false);
/// With `VST3_FIXTURE_LATE_STATE` set, the first plugin made sets up state that the process exit
/// tears down, like a static a real plugin initialises lazily; the module exit needs it.
static LATE_STATE_REGISTERED: AtomicBool = AtomicBool::new(false);
static LATE_STATE_GONE: AtomicBool = AtomicBool::new(false);
unsafe extern "C" {
fn atexit(f: extern "C" fn()) -> c_int;
fn _exit(status: c_int) -> !;
}
/// Counts itself in one of the counters above for as long as it exists.
struct Live(&'static AtomicI64);
impl Live {
fn new(counter: &'static AtomicI64) -> Live {
counter.fetch_add(1, Ordering::SeqCst);
Live(counter)
}
}
impl Drop for Live {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
/// Ends the process at once with status 3 (no crash report: one test expects this).
fn violation(what: &str) -> ! {
eprintln!("vst3-fixture: host contract violation: {what}");
unsafe { _exit(3) }
}
/// The OS id of the calling thread (`std::thread::current` is gone in exit handlers).
fn os_thread() -> usize {
#[cfg(unix)]
{
unsafe extern "C" {
fn pthread_self() -> usize;
}
unsafe { pthread_self() }
}
#[cfg(windows)]
{
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetCurrentThreadId() -> u32;
}
unsafe { GetCurrentThreadId() as usize }
}
}
fn module_entry() -> bool {
if ENTERED.fetch_add(1, Ordering::SeqCst) == 0 {
ENTRY_THREAD.store(os_thread(), Ordering::SeqCst);
}
if !TEARDOWN_REGISTERED.swap(true, Ordering::SeqCst) {
unsafe { atexit(static_teardown) };
}
true
}
/// Called whenever the factory makes a plugin object.
fn set_up_late_state() {
if std::env::var_os("VST3_FIXTURE_LATE_STATE").is_some() && !LATE_STATE_REGISTERED.swap(true, Ordering::SeqCst) {
unsafe { atexit(late_state_teardown) };
}
}
extern "C" fn late_state_teardown() {
LATE_STATE_GONE.store(true, Ordering::SeqCst);
}
fn module_exit() -> bool {
let left = ENTERED.fetch_sub(1, Ordering::SeqCst) - 1;
if left < 0 {
violation("module exit without a module entry");
}
let (factories, objects) = (FACTORIES.load(Ordering::SeqCst), OBJECTS.load(Ordering::SeqCst));
if left == 0 && (factories != 0 || objects != 0) {
violation(&format!("module exit with {factories} factories and {objects} plugin objects alive"));
}
if left == 0 && os_thread() != ENTRY_THREAD.load(Ordering::SeqCst) {
violation("module exit on another thread than the module entry");
}
if LATE_STATE_GONE.load(Ordering::SeqCst) {
violation("module exit after the process exit tore down state the module set up while in use");
}
true
}
/// Stands in for the C++ static destructors a real module registers while it loads.
extern "C" fn static_teardown() {
if ENTERED.load(Ordering::SeqCst) > 0 && OBJECTS.load(Ordering::SeqCst) == 0 {
violation("static teardown of a module that is not in use but was never exited");
}
}
#[cfg(target_os = "windows")]
#[unsafe(no_mangle)]
extern "system" fn InitDll() -> bool {
true
module_entry()
}
#[cfg(target_os = "windows")]
#[unsafe(no_mangle)]
extern "system" fn ExitDll() -> bool {
true
module_exit()
}
#[cfg(target_os = "macos")]
#[unsafe(no_mangle)]
extern "C" fn bundleEntry(bundle: *mut c_void) -> bool {
!bundle.is_null()
!bundle.is_null() && module_entry()
}
#[cfg(target_os = "macos")]
#[unsafe(no_mangle)]
extern "C" fn bundleExit() -> bool {
true
module_exit()
}
#[cfg(all(unix, not(target_os = "macos")))]
#[unsafe(no_mangle)]
extern "C" fn ModuleEntry(handle: *mut c_void) -> bool {
!handle.is_null()
!handle.is_null() && module_entry()
}
#[cfg(all(unix, not(target_os = "macos")))]
#[unsafe(no_mangle)]
extern "C" fn ModuleExit() -> bool {
true
module_exit()
}
#[unsafe(no_mangle)]
extern "system" fn GetPluginFactory() -> *mut IPluginFactory {
match ComWrapper::new(Factory).to_com_ptr::<IPluginFactory>() {
match ComWrapper::new(Factory { _live: Live::new(&FACTORIES) }).to_com_ptr::<IPluginFactory>() {
Some(p) => p.into_raw(),
None => std::ptr::null_mut(),
}
+6 -1
View File
@@ -212,7 +212,12 @@ fn state_saves_component_and_controller_and_restores_them() {
fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
let id = id_of("Fixture Gain");
let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap();
assert_eq!(p.editor_size(), Ok((320, 240)));
// Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported".
if cfg!(target_os = "macos") {
assert_eq!(p.editor_size(), Ok((320, 240)));
} else {
assert!(p.editor_size().is_err());
}
let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");
// The fixture "edits" Gain to 0.9 (normalized) when it gets the component handler.
assert_eq!(ed.idle(), vec![("7".to_string(), 1.8)]);
+14
View File
@@ -0,0 +1,14 @@
//! At process exit the host runs the module exit of every module it loaded, after releasing the
//! factory and before the module's statics are torn down (`harness = false`: the process exit is
//! what is under test). The fixture aborts the process if that order breaks, much as real plugins
//! crash: HALion Sonic segfaulted in its static destructors after a plain plugin scan.
mod common;
fn main() {
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
drop(p);
println!("module_exit: ok");
}
@@ -0,0 +1,14 @@
//! A module with a plugin instance still alive at process exit keeps running: its module exit
//! would pull the module out from under that instance (`harness = false`, see `module_exit.rs`).
//! The fixture aborts the process if its module exit runs while any of its objects are alive.
mod common;
fn main() {
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
// Still alive at exit, like a plugin held by a mixer that is never torn down.
std::mem::forget(p);
println!("module_exit_live_instance: ok");
}
@@ -0,0 +1,44 @@
//! A plugin that sets up state while in use (HALion Sonic does) needs its module exit before the
//! process exit tears that state down: `shutdown()` runs the exits up front, while an exit
//! handler comes too late (`harness = false`, see `module_exit.rs`). Each case runs in a child
//! process with the fixture's late state on (`VST3_FIXTURE_LATE_STATE`); the fixture ends the
//! process with status 3 when its module exit comes after the teardown.
mod common;
use soundcraft_dsp::Plugin;
use std::process::{Command, Output};
/// Makes, uses and releases a plugin, then shuts plugin hosting down unless `exit_handler_only`.
fn child(exit_handler_only: bool) {
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
p.prepare(48_000.0, 64, 2);
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
drop(p);
if !exit_handler_only {
soundcraft_vst3_host::shutdown();
assert!(soundcraft_vst3_host::instantiate_plugin(&gain).is_err(), "loads fail after shutdown");
}
}
fn run(case: &str) -> Output {
Command::new(std::env::current_exe().expect("test binary")).arg(case).env("VST3_FIXTURE_LATE_STATE", "1").output().expect("run the child")
}
fn main() {
match std::env::args().nth(1).as_deref() {
Some("shutdown") => return child(false),
Some("exit-handler") => return child(true),
_ => {}
}
let up_front = run("shutdown");
assert!(up_front.status.success(), "shutdown before exit: {:?}\n{}", up_front.status, String::from_utf8_lossy(&up_front.stderr));
let too_late = run("exit-handler");
let err = String::from_utf8_lossy(&too_late.stderr);
assert_eq!(too_late.status.code(), Some(3), "the exit handler alone comes too late: {err}");
assert!(err.contains("module exit after the process exit tore down state"), "{err}");
println!("module_exit_shutdown: ok");
}
@@ -0,0 +1,17 @@
//! A module first loaded from a short-lived thread is entered and, at process exit, exited on
//! one thread that outlives it (`harness = false`, see `module_exit.rs`): the asking thread is
//! gone by then, and modules tie state to the thread of their entry (Massive X crashed in a
//! module exit run on another thread). The fixture aborts the process if the threads differ.
mod common;
fn main() {
std::thread::spawn(|| {
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
drop(soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate"));
})
.join()
.expect("worker thread");
println!("module_exit_worker_thread: ok");
}
+6
View File
@@ -41,6 +41,12 @@ process in parallel; plugin delay compensation aligns every path. The same engin
bounces offline (`render_range`) and realtime playback (`soundcraft_playback::Player`, which owns
it on the audio thread and receives new session snapshots through a channel).
The cpal callbacks mark their thread (`soundcraft_playback::mark_audio_thread`). Code that may run
there can ask `on_audio_thread()` before doing anything that blocks; the desktop app's logger does,
so a `log::` record from the audio thread (a stream error, a full synth event queue, a hosted
plugin's failed `process`, a CLAP plugin's own log call) is kept without waiting and written later
by the UI thread (see README › Logs).
## Agent control
The desktop app's control channel (JSON lines over TCP) exposes engine commands, inspection,
+1 -1
View File
@@ -30,7 +30,7 @@ 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, 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, 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). |
+2 -2
View File
@@ -23,8 +23,8 @@ 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). |
| `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection. |
| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). |
| `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). |
| `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. |
+3
View File
@@ -94,6 +94,9 @@ plugins), and WebAudio in the browser.
- **Notarization:** the app is zipped and sent with `xcrun notarytool submit --wait`, then the
ticket is stapled to the app. The app goes on a DMG (`hdiutil`, with an `Applications` link).
The DMG is signed, notarized and stapled too.
Its Finder window (background, icon size and positions) comes from
[`packaging/macos/dmg/`](../packaging/macos/dmg/README.md), and its volume is named `SoundCraft`
without the version, which the window's background needs; the DMG file name keeps the version.
- **CLI:** the universal `soundcraft-cli` is signed with the hardened runtime, zipped and
notarized.
+44
View File
@@ -0,0 +1,44 @@
# macOS DMG window
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).
| File | What |
|---|---|
| `background.svg` | Source of the background: the app icon (`assets/app-icon/icon.svg`, linked, not copied) cropped as a cover on the SoundCraft colour field (`#14a9c4`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. |
| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. |
| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, SoundCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
## The volume name has no version
The mounted volume is called `SoundCraft`, not `SoundCraft <version>`. The layout points at the background
through an alias that includes the volume name, and Finder resolves it by that name: with a
versioned name the window keeps its size and icon positions but shows no background (tested).
The DMG file name (`soundcraft-<version>-macos-<arch>.dmg`) still carries the version.
## Rules
- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
so the area under both icons stays light (Paper).
- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
the label strip under it.
## Regenerate
Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
```sh
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
python3 packaging/macos/dmg/generate.py # needs resvg on PATH
```
Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt
title bar; the content area is 660 × 400.
This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493).
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.
Binary file not shown.
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here).
Needs resvg on PATH and:
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing
from the machine that made them: no local paths, user or disk names, dates or volume UUIDs.
python3 packaging/macos/dmg/generate.py
"""
import datetime
import os
import shutil
import subprocess
import sys
import tempfile
from ds_store import DSStore
from mac_alias import Alias, TargetInfo, VolumeInfo
from PIL import Image, ImageCms
HERE = os.path.dirname(os.path.abspath(__file__))
# Keep in sync with package.sh (volume name) and README.md (layout).
VOLUME = "SoundCraft"
WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size
TITLE_BAR = 32
ICON_SIZE = 128
PALETTE = 16 # colours in background.tiff
ICONS = {"SoundCraft.app": (326, 205), "Applications": (574, 205)}
# A fixed date for the alias and the colour profile. Finder never matches it against the image
# (each build is a new volume): it finds the background by volume name and path.
FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc)
def render(svg, width, out):
# The SVG's text is already outlined and no fonts are loaded, so every machine draws the
# same pixels.
subprocess.run(
["resvg", "--skip-system-fonts", "-w", str(width), svg, out],
check=True,
)
with Image.open(out) as im:
return im.convert("RGB")
def quantize(im):
# No dithering: dither noise compresses badly and the artwork has flat fills.
return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
def srgb_profile():
# LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed.
icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
d = FIXED_DATE
icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0))
if icc[84:100] != bytes(16):
sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date")
return bytes(icc)
def write_tiff(svg, out):
"""1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them."""
with tempfile.TemporaryDirectory() as tmp:
one, two = (
quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2)
)
two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page
# Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette
# keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's.
one.save(
out,
"TIFF",
save_all=True,
append_images=[two],
compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate)
dpi=(72, 72),
icc_profile=srgb_profile(),
)
with Image.open(out) as im:
im.seek(1)
if im.info.get("dpi") != (144, 144):
sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)")
strips = []
for page in (0, 1):
im.seek(page)
strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts
# TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff
# leaves the padding byte after it uninitialised, so the file changed from run to run with
# identical pixels. Zero it: nothing refers to that byte.
starts = {o for o, _ in strips}
with open(out, "r+b") as f:
for o, n in strips:
if (o + n) % 2 and o + n not in starts:
f.seek(o + n)
f.write(bytes(1))
def background_alias():
# What Finder stores for /.background/background.tiff on the volume, minus anything about the
# machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable),
# the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids
# are only hints, as the volume's creation date.
volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0")
volume.posix_path = f"/Volumes/{VOLUME}"
target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0")
target.folder_name = ".background"
target.cnid_path = [25]
target.carbon_path = f"{VOLUME}:.background:\0background.tiff"
target.posix_path = "/.background/background.tiff"
return Alias(volume=volume, target=target).to_bytes()
def write_ds_store(out):
if os.path.exists(out):
os.remove(out)
with DSStore.open(out, "w+") as d:
d["."]["bwsp"] = {
"ShowStatusBar": False,
"ShowToolbar": False,
"ShowTabView": False,
"ContainerShowSidebar": False,
"ShowSidebar": False,
"WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}",
}
d["."]["icvp"] = {
"viewOptionsVersion": 1,
"backgroundType": 2,
"backgroundImageAlias": background_alias(),
"backgroundColorRed": 1.0,
"backgroundColorGreen": 1.0,
"backgroundColorBlue": 1.0,
"gridOffsetX": 0.0,
"gridOffsetY": 0.0,
"gridSpacing": 100.0,
"arrangeBy": "none",
"showIconPreview": True,
"showItemInfo": False,
"labelOnBottom": True,
"textSize": 16.0,
"iconSize": float(ICON_SIZE),
}
d["."]["vSrn"] = ("long", 1)
for name, pos in ICONS.items():
d[name]["Iloc"] = pos
def main():
if not shutil.which("resvg"):
sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)")
write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff"))
write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store"))
if __name__ == "__main__":
main()
+6 -1
View File
@@ -129,10 +129,15 @@ STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/SoundCraft.app"
ln -s /Applications "$STAGE/Applications"
# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
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.
hdiutil makehybrid -hfs -hfs-volume-name "SoundCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
# 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"
sign "$DMG"