Compare commits

..
113 Commits
Author SHA1 Message Date
FredApps 7bf178835f Copy the shared house rules into AGENTS.md.
The blocks come from craft-contrib at dde09ea31e2e457f5b4653a6e76f7cdf55944bdf. CLAUDE.md imports AGENTS.md. The house-rules workflow checks the copy.
2026-11-06 11:50:02 +01:00
SlyLaM e66a5e7c67 feat(ui): add complete Preferences window and live settings (#819)
* feat(ui): add complete Preferences window shell

* fix(ui): refine Preferences layout and live settings

* fix(ui): remove Preferences page scrolling

* fix(ui): keep Preferences window size constant

* feat(ui): add Preferences to the SoundCraft menu

* fix(ui): address Preferences review feedback

* docs(ui): document Preferences behavior

* fix(macOS): add Preferences to the application menu

* fix(ui): make Preferences menu behavior modal

* fix(ui): preserve live Preferences updates

* fix(ui): address latest Preferences review findings

* fix(ui): preserve Preferences button actions
2026-11-06 11:34:43 +01:00
FredApps 87f46cf23b Merge pull request #782 from SlyLaM/fix/transient-record-monitor-states
fix(engine): keep live input states transient
2026-11-06 07:26:56 +01:00
Zinadin ZidanandClaude Opus 5.5 54cb247e12 fix(ui): Strip Silence opens with the values it last had (#1382) (#1396)
Strip Silence always opened at -48 dB / 50 / 5 / 20 ms. Its four values are
now kept for the session next to the strip names and written back each frame
the window is shown, so Escape, Cancel, Strip, Extract, Separate and the
Rename round trip all keep them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:20:57 +01:00
FredApps 73eeb692fe Merge pull request #1370 from ZIDAN44/fix/892-session-json-cap
fix(engine): cap sessions at 256 MiB and parse them without a Value tree (#892)
2026-11-05 15:01:35 +01:00
FredApps bd75fe691b Merge pull request #1371 from ZIDAN44/fix/901-clap-main-thread
fix(clap-host): check for the main thread off macOS too (#901)
2026-11-05 15:00:52 +01:00
FredApps 6c8a9dd300 Merge pull request #1372 from ZIDAN44/fix/744-commands-json
feat(cli): commands --json lists every command's details (#744)
2026-11-05 14:57:21 +01:00
Sly LaMenace d393730b02 Merge upstream/main and adapt transient live input states 2026-11-05 07:51:42 +00:00
Zinadin ZidanandClaude Opus 5.5 4240ffda42 fix(engine): cap sessions at 256 MiB and parse them without a Value tree (#892)
A session was parsed into a serde_json Value and then converted, which takes
many times the file's size in memory, and nothing limited how big a session
file could be.

Session::from_json now reads only format_version first (every other field is
skipped) and then parses the text straight into the session. A missing
format_version and a newer version give the errors they gave before. Other
type errors now end with the place they were found ("at line L column C").
A field of the document given twice in one object, which used to load with
the last value winning, is now refused with "duplicate field `key`" (a
twice-given format_version too, not "missing format_version"): keeping the
old leniency would need the tree this change removes, and no writer repeats
a field. Keys of maps (plugin params, edit values and the like) and unknown
keys still keep the last value given, as before.

A track's height was read through an untagged enum, which buffers the whole
value before it looks at it; a huge array there took about 27 times its size.
It now has its own reader that takes exactly the forms that loaded before
("Micro" to "Extreme", also as {"Micro": null}, {"Custom": n}, {"Fit": n},
and the old bare "Fit" or {"Fit": null}) and refuses anything else at its
first token.

Opening a session (a .scraft file, the browser's bytes, the session in a
bundle) and Import Session Data refuse one over 256 MiB by its size, before
reading it. Save, Save Copy In, Save As Template, autosave and the browser's
bundle refuse to write a session over that size, so SoundCraft never saves
one it then won't open. Save and autosave check before they write any audio,
so a refused save leaves nothing behind and a refused autosave keeps the last
one and its audio copies as they were; the refusal is reported like any
other autosave failure. Save checks against the longest name each new audio
file can get, so it may refuse a session within a few bytes per new file of
the limit. Crash recovery reads only the name of a leftover autosave, and
none from one over the cap. Audio files are read without that cap, as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:47:13 +06:00
Zinadin ZidanandClaude Opus 5.5 c046c3bb1b feat(cli): commands --json lists every command's details (#744)
soundcraft-cli commands printed only a text table, so a program that wanted
the parameters had to call describe once per command. With --json it prints a
JSON array of each command's details, the same object describe prints, as
PhotoCraft and FilmCraft do; --compact puts it on one line. The filter
applies, and no match prints []. As in describe, enabled and disabled_reason
are worked out against an empty session; docs/cli.md says so, and the README
mentions the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:20:58 +06:00
Zinadin ZidanandClaude Opus 5.5 e6cbf76148 fix(clap-host): check for the main thread off macOS too (#901)
On Windows and Linux on_main_thread() always said yes, so any thread could
open, idle or close a CLAP editor, and an instance dropped on another thread
destroyed its editor there. CLAP allows clap.gui calls on the main thread
only.

It now uses the rule the CLAP host's Windows window code and the VST3 host's
module loading already use: the thread Rust names "main", where the app runs
its UI. The editor tests check that another thread, named or not, is refused
on every platform, and that a plugin dropped on another thread with its
editor open warns and leaves the editor instead of destroying it there (the
test plugin now allows that, and only that, at destroy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:17:50 +06:00
FredAppsandClaude Opus 5.5 1db9563379 fix(ui): About credits tables fit without side-scrolling and their columns resize (#1342)
* fix(ui): About credits tables fit without side-scrolling and their columns resize

The Contributors table and the Models table were egui Grids inside a
two-way scroll area: they opened wider than the dialog, so they scrolled
sideways, and Company and Model sat almost touching. Both are now
egui_extras tables. Each column opens at the width of its header and widest
cell, the spare width goes to the text columns (Name; Company and Model),
and every column edge can be dragged. Widening a column grows the table
like a spreadsheet, and only then does it scroll sideways; the header stays
put while the rows scroll. Counts and dates are right-aligned and every
cell is vertically centred.

The sorted column stays highlighted and its direction shows on the
toolbar's sort-order button, so headers no longer reserve room for an
arrow. The opening widths come from fit_widths, a pure function with unit
tests that a fitted table fills its room exactly and that only the text
columns give way when room is short.

The About dialog opens 820 points wide, so every column fits at its natural width.

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

* Let every credits column be dragged, and refit changed tables

The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

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

* Refit credits tables on what they measure, not the fitted widths

Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:33:36 +01:00
FredApps 341a112757 Merge pull request #589 from SlyLaM/feat/grid-nudge-time-scales
Complete Grid and Nudge time scales
2026-11-05 02:45:03 +01:00
Sly LaMenace 594d0ade4f Merge upstream/main into feat/grid-nudge-time-scales 2026-11-04 22:55:33 +00:00
Sly LaMenace 92e6973640 Merge upstream/main and fix frame-origin grid snapping 2026-11-04 22:54:15 +00:00
Dylan M. Taylor 2b03df3ad4 Pass the Linux signing key to the release workflow (#1363) 2026-11-04 17:45:35 -05:00
Dylan M. Taylor 5fabf28c31 test: create scratch folders exclusively (#1334) 2026-11-03 23:03:28 -05:00
Dylan M. Taylor fa34e0c182 fix(engine): dedupe clip ids, clamp score bars per system, share autosave copies (#1333) 2026-11-03 23:02:33 -05:00
Dylan M. Taylor ccebb8ff93 fix(video): bound H.264 gap frames, long-term references and coded area (#1325) 2026-11-03 23:01:59 -05:00
Dylan M. Taylor b7d78c7e9e fix(time): edit meters in linear time (#1324) 2026-11-03 17:49:25 -05:00
FredApps b26ab2b25c Merge pull request #576 from colliderman/fix/edit-track-header-alignments
fix(ui): center track names in edit window and remove unnecessary spacing for Solo and Mute buttons in Master tracks
2026-11-03 21:01:19 +01:00
Cody Gilchrist 76f58fcb81 fix(ui): add hover tooltip for overflowing track names and assert exact center position in test 2026-11-03 12:29:37 -05:00
Cody Gilchrist 6a4c055094 fix(ui): preserve start of long track names in edit headers 2026-11-03 11:47:04 -05:00
CodyG_NeuralDSP d5564bce05 fix(ui): position master track view selector directly under track name 2026-11-03 11:00:53 -05:00
CodyG_NeuralDSP 87ad11cccc fix(ui): center track names in edit window track headers 2026-11-03 10:58:53 -05:00
Brandon Thomas b11ffe73ae Merge pull request #1280 from TheGalitube/fix/1252-tracks-nested-in-a-folder-are-no
Indent tracks nested in folders
2026-11-03 10:05:11 -05:00
Brandon Thomas f91b4173b6 Merge pull request #1281 from rahul05ranjan/toolbar-link-icons
Toolbar: distinct link icons (#1262) and an Insertion Follows Playback toggle with its own icon (#1263)
2026-11-03 10:05:06 -05:00
Brandon Thomas 98c3a307c3 Merge pull request #1316 from JonRC/fix/plugin-load-error-reason
Plugin hosts: keep the system's reason when a plugin binary fails to load
2026-11-03 10:05:01 -05:00
Brandon Thomas 6492b5f27e Merge pull request #1286 from Silvertree2010/fix/1260-zoom-in-t-doesn-t-use-the-edit-c
Keep the edit cursor as the zoom reference when it is off-screen
2026-11-03 10:04:56 -05:00
Brandon Thomas 9c202d6f17 Merge pull request #1290 from JonRC/fix/1289-offline-render-mode
Tell hosted plugins when they render offline (VST3 kOffline, clap.render)
2026-11-03 10:04:51 -05:00
Brandon Thomas 44b864ffaf Merge pull request #1305 from JonRC/fix/1304-cli-logger
soundcraft-cli: print logged warnings and errors to stderr
2026-11-03 10:04:47 -05:00
Brandon Thomas 035de82c84 Merge pull request #1278 from TheGalitube/fix/1257-alt-click-doesn-t-delete-a-marke
Delete markers on Alt-click
2026-11-03 10:04:42 -05:00
Brandon Thomas 181f329ec8 Merge pull request #1279 from TheGalitube/fix/1255-stereo-track-input-shows-a-mono
Show stereo input pairs for stereo tracks
2026-11-03 10:04:37 -05:00
Brandon Thomas 5ba9f9d7b8 Merge pull request #1291 from ljkx/new-send-opens-send-window
A new send opens its Send window, so its fader is on screen right away
2026-11-03 10:04:32 -05:00
Brandon Thomas c15682a088 Merge pull request #1302 from JonRC/fix/1301-audiosuite-hosted-ids
audiosuite.process: say it takes built-in plugin ids, and why hosted ones are refused
2026-11-03 10:04:28 -05:00
Brandon Thomas 0d253b6d8c Merge pull request #1300 from JonRC/fix/1296-bounce-bext
Stamp WAV bounces with their timecode position (BWF bext TimeReference)
2026-11-03 10:04:22 -05:00
Brandon Thomas c0f2f7dc87 Merge pull request #1314 from JonRC/fix/convert-keeps-bext
convert: keep a BWF source's bext chunk
2026-11-03 10:04:16 -05:00
Brandon ThomasandClaude Opus 5.5 f99407fb1f Leave Insertion Follows Playback's N to the Edit window's shortcut handler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:56:42 -05:00
Brandon ThomasandClaude Opus 5.5 636454e639 Name the missing entry point in the not-a-plugin errors on every platform
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:56:35 -05:00
Brandon ThomasandClaude Opus 5.5 7546ab992b Cap the folder indent at eight levels and test against the track's own folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:56:05 -05:00
Brandon ThomasandClaude Opus 5.5 f86b077cdf Test that an off-screen edit cursor lands on its fixed column
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:53:19 -05:00
Dylan M. Taylor aa250cc1d7 Clip gain to volume: index envelope lookups (#1309)
Converting clip gain to volume (and baking volume into clip gain)
evaluated the clip's gain envelope by walking it from the start for
every breakpoint being converted, so a large envelope in an opened or
imported session cost envelope points squared and blocked the engine.

Build a running-maximum index over the envelope once per clip and
binary-search it. It returns exactly what the walk returned, including
for envelopes stored out of order.
2026-11-03 07:44:31 -05:00
Sly LaMenace dbcbcbac00 Merge upstream/main into feat/grid-nudge-time-scales 2026-11-03 11:52:00 +00:00
Dylan M. Taylor 351b9d3a55 Tempo map: binary-search tempo segments in tick/sample conversions (#1297)
* Tempo map: index tempo segments so tick/sample conversions are O(log n)

Every tick<->sample conversion walked all earlier tempo changes, so code
that converts many positions (MIDI import/export, the tempo ruler, score
layout, MIDI scheduling and controller chasing on the audio thread)
cost positions x tempo changes. A session or MIDI file with many tempo
changes and many notes or tracks could stall the engine or the audio
callback.

TempoMap now keeps the elapsed time at each tempo change (rebuilt on
every edit and on deserialize, never serialized) and binary-searches
the segment. MIDI scheduling also skips notes whose on and off both
fall outside the block before converting them.

* Tempo map: index a file's ticks with saturating subtraction
2026-11-03 06:18:44 -05:00
Dylan M. Taylor da02c19fcc Bundles: refuse entries that share their data (#1303)
Bundle entries are stored, so the files read out of one can only
outgrow the bundle when several entries point at the same bytes. A
small bundle naming one payload thousands of times was copied out once
per name, up to the 1 GiB total limit, in the browser. Check the
archive for overlapping entry data before reading any entry.
2026-11-02 23:33:19 -05:00
Dylan M. Taylor 64540f109b Mixer: compute cyclic aux depths in one pass per hop (#1306)
When the aux/folder routing has a cycle, processing_order sorted the
auxes by a bounded feeder depth recomputed inside the sort key: each
evaluation re-searched every aux's output and sends against a growing
list of inputs, for up to eight hops, on every comparison. A session
with many auxes sharing buses made that cubic during audio sync.

Compute the same capped depth for every track at once: per hop, collect
the buses written by auxes reached so far, then mark the tracks reading
them. The result matches the old search (tested against it on a mixed
graph with cycles) and the sort uses it by lookup.
2026-11-02 23:27:09 -05:00
Dylan M. Taylor 0af1ad909e Mixer: match automation lanes to insert parameters in one merge (#1299)
apply_params searched all of the track's automation lanes for every
stored insert parameter on every block, so a session with many
parameters and many lanes multiplied the two on the audio thread.
Collect the slot's lanes once per block, order them by parameter name
(keeping the first lane per name, the one the old search found) and
walk them alongside the sorted parameter map. The lane list reuses a
scratch buffer kept on the plugin slot.
2026-11-02 23:25:34 -05:00
JonRC 7587130d59 convert: keep a BWF source's bext chunk
convert wrote every file with bwf: None, so converting a Broadcast Wave file
dropped its bext: description, originator, dates and the time reference that
places it on a timeline. Pass the decoded bext on. The time reference counts
samples, so with --rate it moves to the output rate.
2026-11-03 01:19:13 -03:00
JonRC e4f0599a2a Plugin hosts: keep the system's reason when a plugin binary fails to load
libloading 0.9 prints only "dlopen failed" ("dlsym failed", "LoadLibraryExW
failed") and keeps the dlerror text or the Windows error as the error's source.
The CLAP and VST3 loaders formatted the error alone, so a plugin built for a
newer glibc was reported as "dlopen failed" with no reason. Append the source.
2026-11-03 01:19:13 -03:00
Dylan M. Taylor 17d3f00700 MIDI: index controller dedup by channel and kind (#1298)
* MIDI: index controller dedup by channel and kind

Remove Duplicate Notes rescanned every controller already kept at the
same tick for each incoming one. A session whose sequence holds one tick
with many distinct channel/controller pairs made that quadratic and
blocked editing. Track where each channel+kind sits at the current tick
in a map instead; the last event per key still wins.

* MIDI: tag dedup index entries with their tick instead of clearing per tick

Clearing the map on every tick change cost O(capacity) per tick after a
dense tick. Store the tick with each entry and treat entries from an
earlier tick as absent. Add an oracle test comparing against the
previous per-tick rescan on mixed-tick input.
2026-11-02 22:51:02 -05:00
JonRC f46ea7d70c soundcraft-cli: print logged warnings and errors to stderr
The CLI installed no logger, so every log::warn! and log::error! was
dropped: a plugin that failed to load just vanished from
engine.clap_plugins with no reason. A small logger now writes warnings
and errors to standard error as `warning: <message>` and
`error: <message>`, like the CLI's own errors. The level is fixed:
RUST_LOG stays a desktop app variable.

Fixes #1304.
2026-11-02 23:48:01 -03:00
JonRC 1403bbc4d3 audiosuite.process: say it takes built-in plugin ids
Its help said `<plugin id>`, but it only runs built-in plugins, and a
hosted id (clap:, vst3:, au:) failed as "unknown process". The help now
says built-in plugin id, and a hosted id gets an error saying AudioSuite
runs built-in plugins only.

Fixes #1301.
2026-11-02 23:42:08 -03:00
JonRC b0793ca875 Stamp WAV bounces with their timecode position (BWF bext)
Bounces passed no bwf to the encoder, so mix and stem WAVs had no bext
chunk and post tools placed them at zero. Bounce::new now stamps them
with TimeReference = the session's timecode start plus the bounce start,
in samples since midnight, as other DAWs do. A caller's own bwf wins;
AIFF and FLAC have no bext and are unchanged.

Fixes #1296.
2026-11-02 23:36:15 -03:00
Dylan M. Taylor 1c350101bc clap-host tests: load plugins only from a freshly created private temp directory (#1295)
* clap-host tests: scan a freshly created private directory for the garbage-file test

The test reused a PID-named directory under the shared temp dir after ignoring a
failed cleanup, then loaded every .clap candidate in it. Another local account
could pre-create that directory and plant a native library that the test runner
would load. Create the directory exclusively (mode 0700 on Unix) and fail
instead of reusing one that already exists.

* clap-host tests: create the garbage-file test directory beside the test binary, not under TMPDIR
2026-11-02 21:30:40 -05:00
ljkx 36d42e0136 Test a new send's Send window end to end, and tidy send_route_menu
An integration test creates send C while send A's window is open: the window moves to the new send
with its fader on screen, and undoing the send closes it. send_route_menu now opens the window only
after the send command succeeds, without the extra bool, and its comment says reassignment leaves
the window alone.
2026-11-03 01:55:52 +01:00
Sly LaMenace c692648200 Merge upstream/main into feat/grid-nudge-time-scales 2026-11-03 00:36:13 +00:00
Sly LaMenace bf274560ae fix(grid): share visible lines and match folder visibility 2026-11-03 00:26:25 +00:00
Brandon ThomasandClaude Opus 5.5 b35dd0f9eb Release 0.17.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:25:27 -05:00
Sly LaMenace 087978a4a0 fix(edit): preserve overlaps in stationary nudge and quantize 2026-11-02 23:42:44 +00:00
Sly LaMenace 9b5554e312 Merge upstream/main into feat/grid-nudge-time-scales 2026-11-02 23:42:44 +00:00
ljkx 04dab602c7 A new send opens its Send window, so its fader is on screen right away (#1269)
Picking a destination for an empty send slot, or making one with new bus..., now opens that send's Send window, as in Pro Tools. Moving an existing send to another bus leaves the window as it was.
2026-11-02 19:38:53 +01:00
FredApps e5d3174929 Merge pull request #637 from danielmigueltejedor/fix/gnome-window-decorations
Fix GNOME/Wayland window controls
2026-11-02 19:32:54 +01:00
FredApps 27329587c6 Merge current main into fix/gnome-window-decorations 2026-11-02 19:10:30 +01:00
FredApps 1dbc185e01 Merge pull request #559 from SlyLaM/fix/new-tracks-keyboard-ux
feat(ui): improve the New Tracks creation workflow
2026-11-02 18:23:48 +01:00
JonRC 576ff36f61 Tell hosted plugins when they render offline
Bounces ran VST3 plugins with processMode kRealtime and never set
clap.render, so plugins that work differently offline never could.
Offline renders now create their plugins with Plugin::set_offline(true):
VST3 plugins get kOffline in setupProcessing and in every process call,
and CLAP plugins with clap.render are set to CLAP_RENDER_OFFLINE.
Playback stays realtime.

Fixes #1289.
2026-11-02 12:34:32 -03:00
NetterSilverandClaude Haiku 5.5 4b06407dae Keep the edit cursor as the zoom reference when it is off-screen
Fixes #1260

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 15:57:40 +01:00
rahul05ranjan 1a95692f50 Toolbar: distinct link icons, an Insertion Follows Playback toggle, and its N shortcut in the menu
The two link toggles shared the "link" icon (#1262), and Insertion Follows
Playback, although it works with N, had no toolbar button of its own and no
shortcut in the Options menu (#1263).

- icons: "link_track" draws the chain under two track rows so the track link
  reads apart from the timeline link; "insertion_follows" draws the insertion
  line and the playhead arrow it follows.
- toolbar: the option toggles move into option_toggles, which gains the
  insertion-follows entry between the links and Mirrored MIDI, as in Pro Tools.
- options: the Insertion Follows Playback menu item shows N, the key it uses.
- test: option_toggles_have_distinct_icons fails when any two option toggles
  share an icon (it fails on the old "link" pair).

Fixes #1262, fixes #1263

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-11-02 12:15:55 +00:00
Max MittelstädtandGPT-5.6-Luna 7dfa7e470f Indent tracks nested in folders
Fixes #1252

Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
2026-11-02 08:25:43 +00:00
Max MittelstädtandGPT-5.6-Luna 8f7eb4d777 Show stereo input pairs for stereo tracks
Fixes #1255

Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
2026-11-02 08:23:34 +00:00
Max MittelstädtandGPT-5.6-Luna ffc035ba29 Delete markers on Alt-click
Fixes #1257

Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
2026-11-02 08:23:18 +00:00
Brandon Thomas 8444edf075 Merge pull request #1248 from Silvertree2010/fix/1247-vst3-multi-out-instruments-e-g-s
Accept VST3 multi-out instruments with up to 64 audio buses
2026-11-02 00:30:05 -05:00
NetterSilverandClaude Opus 5.5 6c3a0dfc50 Accept VST3 multi-out instruments with up to 64 audio buses
Fixes #1247

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 06:19:39 +01:00
Brandon Thomas d557d18118 Merge pull request #1223 from TheGalitube/fix/h7732-aiff-parsing-rejects-valid-chann
AIFF: test that a negative channel count is rejected
2026-11-01 23:06:45 -05:00
Brandon Thomas 98cda6ecaf Merge pull request #1032 from Silvertree2010/fix/h2086-midi-export-writes-notes-that-a
Export only the MIDI notes a trimmed clip still plays
2026-11-01 23:06:40 -05:00
Brandon Thomas 920ec2c3f3 Merge pull request #1234 from Silvertree2010/fix/h5095-score-export-drops-every-note-wh
Keep score export notes when bars are shorter than a sixteenth
2026-11-01 23:06:37 -05:00
Brandon ThomasandClaude Opus 5.5 5e8898976e Test a grid of 1/16, 1/32 and 1/64 bars in a row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:06:02 -05:00
Brandon ThomasandClaude Opus 5.5 c426b889bb Test MIDI export of a clip trimmed at both ends
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:05:08 -05:00
Sly LaMenace e1f10ccdc8 fix(edit): preserve cross-track grid snapping 2026-11-01 23:03:35 +00:00
Sly LaMenace 627bbbf9cc fix(edit): keep zero-distance moves lossless 2026-11-01 22:49:21 +00:00
Sly LaMenace 84baa5debc fix(edit): preserve drag origins while snapping 2026-11-01 22:39:15 +00:00
Sly LaMenace 468bec487c fix(ui): exclude trimmed clip from grid targets 2026-11-01 22:01:09 +00:00
Sly LaMenace 71f899baac fix(edit): align grid snapping across time scales 2026-11-01 14:47:41 +00:00
Sly LaMenace ccbeec6680 Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales 2026-11-01 14:34:56 +00:00
Sly LaMenace bdb73895a5 fix(ui): make dialog cancellation atomic 2026-11-01 13:55:09 +00:00
Sly LaMenace 1a4a88fb0d fix(ui): cancel pending dialog actions on Escape 2026-11-01 13:40:45 +00:00
Sly LaMenace 9e902b48fe fix(ui): let dialog cancellation win over Enter 2026-11-01 12:30:51 +00:00
Sly LaMenace 265e526f6a fix(ui): keep row actions from submitting New Tracks 2026-11-01 11:46:36 +00:00
Sly LaMenace d2298ff974 Merge remote-tracking branch 'upstream/main' into fix/new-tracks-keyboard-ux
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
2026-11-01 11:32:53 +00:00
Max MittelstädtandGPT-5.6-Luna cf40296888 fix: reject unsigned AIFF channel counts
Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
Co-Authored-By: GPT-5.6-Terra <noreply@openai.com>
2026-11-01 09:13:52 +00:00
Sly LaMenace 381832447c Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales 2026-11-01 00:09:45 +00:00
NetterSilverandClaude Opus 5.5 b32065a109 Keep score export notes when bars are shorter than a sixteenth
Found while testing crates/engine/src/score.rs:~150 (measure_grid, `if ub <= ua { break; }`), used by layout()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:34:16 +01:00
NetterSilverandClaude Sonnet 5.5 97e2603683 fix: release a held sustain at the end of a trimmed MIDI clip on export
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 16:48:51 +00:00
Max MittelstädtandGPT-6-Luna 73e3478ae3 Accept unsigned AIFF channel counts
Found while testing crates/audio-io/src/aiff.rs:84

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-31 07:06:09 +00:00
NetterSilverandClaude Haiku 5.5 4740e8d4c4 Export only the MIDI notes a trimmed clip still plays
Found while testing crates/engine/src/io.rs:350-362 (export_midi)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 05:24:39 +01:00
Daniel fd9c673694 Merge upstream main and reconcile Linux Wayland CSD dependencies
Preserve Unix X11 support, add Linux Adwaita no-title controls,
refresh Cargo.lock and keep MIT notice in existing packaging paths.
2026-10-28 10:48:57 +01:00
Sly LaMenace d7c194d728 Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales 2026-10-27 15:54:41 +00:00
Sly LaMenace f92150c64e Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales
# Conflicts:
#	crates/ui-egui/src/edit_window.rs
#	crates/ui-egui/src/toolbar.rs
2026-10-27 13:25:40 +00:00
Daniel 1f8b267722 Merge upstream main and resolve GNOME Wayland controls conflicts 2026-10-25 23:57:47 +01:00
Sly LaMenace 66f305b747 fix(ui): handle Grid and Nudge edge cases 2026-10-25 12:30:31 +00:00
Sly LaMenace b9accf92a8 fix(ui): address Grid and Nudge review feedback 2026-10-25 11:13:22 +00:00
Sly LaMenace d33cac774a Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales
# Conflicts:
#	crates/engine/src/cmd/view.rs
#	crates/model/src/session.rs
#	crates/time/src/grid.rs
2026-10-25 07:29:27 +00:00
Sly LaMenace 95f25979eb docs(ui): document New Tracks changes 2026-10-24 21:57:11 +00:00
Sly LaMenace 929a7c5307 Merge remote-tracking branch 'upstream/main' into fix/new-tracks-keyboard-ux
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-24 21:50:52 +00:00
Sly LaMenace ee63b09cbe fix(ui): omit hidden New Tracks row fields 2026-10-24 21:45:19 +00:00
Daniel 4071975aa4 Merge upstream main; retain GNOME window controls
Integrate updated SoundCraft dependencies and packaging while preserving Linux winit CSD, required lockfile crates and license notices.
2026-10-24 10:22:45 +02:00
Sly LaMenace 9e9177f1af fix(ui): hide inapplicable New Tracks fields 2026-10-20 14:33:48 +00:00
Sly LaMenace 3ea1f34a1d feat(ui): add multiple New Tracks rows with fixed field widths 2026-10-20 14:33:48 +00:00
Sly LaMenace 6e8c039097 fix(ui): improve New Tracks keyboard navigation and field sizing 2026-10-20 14:33:15 +00:00
Daniel 5d7197a2d0 fix(linux): keep GNOME Wayland controls without redundant NOTICE copy 2026-10-20 03:53:35 +02:00
Sly LaMenace 93ac4f2913 test(engine): cover enabled live states across history 2026-10-19 10:35:53 +00:00
Sly LaMenace 352d90a24a fix(engine): keep live input states transient 2026-10-19 09:42:51 +00:00
Sly LaMenace b48a939d64 fix(ui): format Grid and Nudge values as counters 2026-10-14 21:15:07 +00:00
Sly LaMenace 7849e8cf51 feat(ui): complete Grid and Nudge time scales 2026-10-14 20:41:55 +00:00
118 changed files with 7750 additions and 649 deletions
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+1
View File
@@ -41,3 +41,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+56 -4
View File
@@ -1,11 +1,62 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# SoundCraft — instructions for agents
SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
Read `ROADMAP.md` for stage, status and the parity numbers, and pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them. `plan/` is an optional gitignored notebook (`plan/protools/` holds an observed menu tree and screenshots, never committed). Do not ask for it and do not stop when it is absent.
## ⚠️ Assets: the absolute rule
**No Avid, Pro Tools, Adobe or other proprietary iconography, images, sounds, presets or artwork — ever.** This is not negotiable.
@@ -30,7 +81,6 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
- The audio callback never blocks, allocates in steady state, or panics.
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## Architecture (layers enforced by `cargo xtask layers`)
@@ -89,3 +139,5 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
+1 -10
View File
@@ -1,10 +1 @@
Read `AGENTS.md`: it is the entry point for agents in this repo.
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
before modules or other crate items; crate-level `//!` documentation may precede them.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
@AGENTS.md
Generated
+102 -23
View File
@@ -277,6 +277,12 @@ dependencies = [
"x11rb",
]
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
@@ -1416,6 +1422,19 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools 0.15.0",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1469,6 +1488,26 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -4006,6 +4045,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [
"log",
"smithay-client-toolkit 0.19.2",
"tiny-skia",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -4280,7 +4330,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"chrono",
"clap",
@@ -4304,7 +4354,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"log",
"serde",
@@ -4314,7 +4364,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -4326,7 +4376,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"clap",
"log",
@@ -4339,7 +4389,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4352,11 +4402,12 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"clap",
"clap_complete",
"clap_mangen",
"log",
"same-file",
"serde",
"serde_json",
@@ -4369,7 +4420,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -4380,7 +4431,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"craft-docpath",
"log",
@@ -4403,7 +4454,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4413,7 +4464,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"log",
"rayon",
@@ -4429,7 +4480,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4442,7 +4493,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"cpal",
"log",
@@ -4457,7 +4508,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4467,11 +4518,12 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"craft-ui",
"craft-update",
"egui",
"egui_extras",
"egui_kittest",
"image",
"log",
@@ -4496,7 +4548,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"craft-media",
"image",
@@ -4508,7 +4560,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4524,7 +4576,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"eframe",
"egui",
@@ -4569,6 +4621,12 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -4939,6 +4997,31 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.5"
@@ -5983,6 +6066,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
@@ -6140,7 +6224,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"serde_json",
]
@@ -6460,11 +6544,6 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
+3 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.16.0"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -60,6 +60,8 @@ craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-l
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
egui = { version = "0.37" }
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
web-time = "1.1"
+31
View File
@@ -286,3 +286,34 @@ The Font Software may be sold as part of a larger software package but no copy o
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
for vector window controls. Its title-font features are disabled: no Cantarell
font is embedded by this dependency and no font file is added to this repo.
sctk-adwaita's MIT licence:
MIT License
Copyright (c) 2022 Bartłomiej Maryńczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+12 -1
View File
@@ -168,6 +168,16 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
new tracks. **Setup › Keyboard Shortcuts** lists them all.
In **New Tracks**, each row has its own count, format, type, Samples/Ticks timebase and name.
Use **+** to add a row below or **−** to remove it; **Create** makes all rows in order, with one
Undo step (up to 128 tracks per creation). The last row stays available. The dropdown widths
fit their longest option and remain fixed when the selection changes.
On macOS, **⌘←/→** changes the active row's format, **⌘↑/↓** its type (also with **⌃**),
and **⌥⌘↑/↓** its timebase. **⇧⌃⌘↓/↑** adds/removes the active row, also with **⌥**;
**⇧⌘↓/↑** works too. Windows/Linux use **Ctrl** for **⌘**. The active row follows the
field you click. A new row starts with its count focused and selected; Enter creates and Escape cancels.
### Logs
The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
@@ -218,7 +228,8 @@ soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
See [`docs/cli.md`](docs/cli.md) for the command-line tool (subcommands, exit codes, environment
variables, man pages and completions), [`docs/control-protocol.md`](docs/control-protocol.md) and
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command.
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command (`--json` for each
one's parameters and details, as JSON).
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
+1
View File
@@ -25,6 +25,7 @@ soundcraft-dsp.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
log.workspace = true
clap.workspace = true
same-file.workspace = true
+6
View File
@@ -168,6 +168,12 @@ pub(crate) struct CommandsArgs {
/// Only the commands whose id, or label in any case, contains this text
#[arg(value_parser = NonEmptyStringValueParser::new())]
pub(crate) filter: Option<String>,
/// Print a JSON array of each command's details, as describe prints them
#[arg(long)]
pub(crate) json: bool,
/// One line of JSON instead of pretty-printed
#[arg(long, requires = "json")]
pub(crate) compact: bool,
}
/// Print one engine command's details as JSON
+9 -5
View File
@@ -7,11 +7,15 @@ use std::io::Write;
pub(crate) fn commands(args: &CommandsArgs, out: &mut dyn Write) {
let filter = args.filter.clone().unwrap_or_default();
let e = Engine::default();
for c in soundcraft_engine::command_specs() {
if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) {
continue;
}
let i = c.info(&e);
let infos = soundcraft_engine::command_specs()
.iter()
.filter(|c| filter.is_empty() || c.id.contains(&filter) || c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()))
.map(|c| c.info(&e));
if args.json {
print_json(out, &infos.collect::<Vec<_>>(), args.compact);
return;
}
for i in infos {
outln!(out, "{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
}
}
+17 -1
View File
@@ -15,6 +15,7 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
let bytes = std::fs::read(input).map_err(|source| CliError::File { path: input.clone(), source })?;
let ext = input.extension().and_then(std::ffi::OsStr::to_str);
let (info, mut buf) = soundcraft_audio_io::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?;
let source_rate = buf.sample_rate;
if let Some(rate) = rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
buf.sample_rate = rate;
@@ -30,13 +31,28 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
let Some(format) = soundcraft_audio_io::FileFormat::from_write_extension(&out_ext) else {
return Err(ConvertError::OutputFormat { output: output.clone(), ext: out_ext }.into());
};
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
// A BWF source keeps its `bext` (written for WAV output only). The time reference counts
// samples, so it moves to the output rate.
let bwf = info.bwf.map(|mut b| {
b.time_reference = at_rate(b.time_reference, source_rate, buf.sample_rate);
b
});
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf };
let bytes = soundcraft_audio_io::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?;
write_replacing(&args.write_path(), &bytes).map_err(|source| CliError::File { path: output.clone(), source })?;
outln!(out, "wrote {} ({} ch, {} Hz, {:.2} s)", output.display(), buf.num_channels(), buf.sample_rate, buf.duration_secs());
Ok(())
}
/// The sample position `pos` at `from` Hz as the nearest sample at `to` Hz.
fn at_rate(pos: u64, from: u32, to: u32) -> u64 {
if from == 0 || from == to {
return pos;
}
let scaled = (u128::from(pos) * u128::from(to) + u128::from(from) / 2) / u128::from(from);
u64::try_from(scaled).unwrap_or(u64::MAX)
}
/// Writes `bytes` to a new file next to `target`, flushes it and renames it over `target`, so `target`
/// (the input, for --in-place) holds either its old or its new contents. The new file is removed when
/// writing or renaming fails.
+1
View File
@@ -11,6 +11,7 @@ mod catalog;
mod convert;
mod error;
mod info;
pub mod logger;
mod mcp;
mod session;
+49
View File
@@ -0,0 +1,49 @@
//! The command line's logger. Without one, every `log::warn!` and `log::error!` in the workspace
//! was dropped: a plugin that failed to load just vanished from `engine.clap_plugins`, and nothing
//! said why. Warnings and errors now go to standard error as `warning: <message>` and
//! `error: <message>`, like the CLI's own errors; standard output keeps only the results.
//! `RUST_LOG` stays a desktop app variable (docs/cli.md), so the level is fixed.
use std::io::Write;
struct StderrLogger;
impl log::Log for StderrLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= log::Level::Warn
}
fn log(&self, record: &log::Record<'_>) {
if self.enabled(record.metadata()) {
// A closed standard error is not worth failing a command over.
let _ = std::io::stderr().write_all(line(record.level(), &record.args().to_string()).as_bytes());
}
}
fn flush(&self) {}
}
/// One record as the CLI prints it.
fn line(level: log::Level, message: &str) -> String {
let label = if level == log::Level::Error { "error" } else { "warning" };
format!("{label}: {}\n", message.strip_suffix('\n').unwrap_or(message))
}
/// Installs the logger. Once per process: `main` calls it, not `run`, which tests call many times.
pub fn install() {
static LOGGER: StderrLogger = StderrLogger;
if log::set_logger(&LOGGER).is_ok() {
log::set_max_level(log::LevelFilter::Warn);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_print_like_the_cli_errors() {
assert_eq!(line(log::Level::Warn, "cannot load CLAP plugin x.clap: bad file\n"), "warning: cannot load CLAP plugin x.clap: bad file\n");
assert_eq!(line(log::Level::Error, "panic: boom"), "error: panic: boom\n");
}
}
+1
View File
@@ -3,6 +3,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
soundcraft_cli::logger::install();
// `args_os`, not `args`: a non-Unicode argument must reach clap, not panic before anything is printed.
let code = soundcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
// The subcommand's engine and its plugin instances are gone by now. This is process teardown,
+29 -1
View File
@@ -5,7 +5,7 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
use soundcraft_audio_io::{AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat};
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id()));
@@ -93,3 +93,31 @@ fn convert_dithers_when_reducing_depth_unless_told_not_to() {
assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn convert_keeps_the_bwf_time_stamp() {
let dir = tmpdir("bext");
let src = dir.join("take.wav");
// 01:00:00:00 at 48 kHz.
let bwf = BwfInfo {
description: "take 3".into(),
originator: "recorder".into(),
origination_date: "2026-11-03".into(),
origination_time: "10:00:00".into(),
time_reference: 172_800_000,
..Default::default()
};
let buf = AudioBuffer::new(48_000, 1, 4800);
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: Some(bwf.clone()) };
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let bext = |p: &Path| soundcraft_audio_io::probe(&std::fs::read(p).unwrap(), None).unwrap().bwf;
let out = dir.join("16.wav");
convert(&src, &out, &["--bit-depth", "16"]);
assert_eq!(bext(&out), Some(bwf.clone()));
// Resampled, the stamp still points at 01:00:00:00: 158,760,000 samples at 44.1 kHz.
let out = dir.join("44k.wav");
convert(&src, &out, &["--rate", "44100"]);
assert_eq!(bext(&out), Some(BwfInfo { time_reference: 158_760_000, ..bwf }));
let _ = std::fs::remove_dir_all(&dir);
}
+19
View File
@@ -88,6 +88,25 @@ fn assert_refused(got: &Invoke, port: u16) {
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
}
// ---- logging ----
/// Warnings SoundCraft logs (a plugin that fails to load, a backup it can't write) go to standard
/// error. Without a logger they were dropped.
#[test]
fn logged_warnings_reach_standard_error() {
let dir = sandbox("log");
let first = invoke(&dir, &["run", "--save", "s.scraft"]);
assert_eq!(first.code, 0, "{}", first.stderr);
// A file where the backups folder should go: the next save that changes the session can't
// back the old one up.
std::fs::write(dir.join("Backups"), b"not a folder").unwrap();
let again = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", r#"track.new={"count":1}"#, "--save", "s.scraft", "--in-place"]);
assert_eq!(again.code, 0, "{}", again.stderr);
assert!(again.stderr.contains("warning: no backup of"), "{}", again.stderr);
assert!(!again.stdout.contains("no backup"), "results only on standard output: {}", again.stdout);
let _ = std::fs::remove_dir_all(&dir);
}
// ---- unknown flags ----
#[test]
+28
View File
@@ -253,3 +253,31 @@ fn script_bounce_without_range_covers_the_session() {
assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}");
let _ = std::fs::remove_dir_all(base);
}
/// `commands --json` lists every command with the details `describe` prints for one (#744).
#[test]
fn commands_json_lists_what_describe_prints() {
let json = |o: &Output| -> Value {
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
serde_json::from_slice(&o.stdout).unwrap()
};
let all = json(&run(&["commands", "--json"]));
let all = all.as_array().unwrap();
let table = run(&["commands"]);
assert_eq!(all.len(), String::from_utf8_lossy(&table.stdout).lines().count());
let bounce = json(&run(&["describe", "file.bounce_mix"]));
assert!(all.contains(&bounce), "{bounce}");
assert!(all.iter().all(|c| c["id"].is_string() && c["params"].is_string() && c["menu"].is_array()));
// The filter applies, and --compact prints one line.
let compact = run(&["commands", "bounce", "--json", "--compact"]);
assert_eq!(String::from_utf8_lossy(&compact.stdout).lines().count(), 1);
let some = json(&compact);
let some = some.as_array().unwrap();
assert!(some.contains(&bounce) && some.len() < all.len(), "{some:?}");
// No match is an empty list, still JSON.
assert_eq!(json(&run(&["commands", "zzzzqq", "--json"])), Value::Array(Vec::new()));
// --compact shapes JSON output only.
let bad = run(&["commands", "--compact"]);
assert_eq!(bad.status.code(), Some(2));
assert!(String::from_utf8_lossy(&bad.stderr).contains("--json"), "{}", String::from_utf8_lossy(&bad.stderr));
}
+1 -1
View File
@@ -151,7 +151,7 @@ fn every_subcommand_has_help_listing_its_arguments() {
),
("run", "run [OPTIONS]", &["--cmd <ID[=JSON]>"]),
("script", "script [OPTIONS] <FILE>", &["<FILE>", "--keep-going"]),
("commands", "commands [FILTER]", &["[FILTER]"]),
("commands", "commands [OPTIONS] [FILTER]", &["[FILTER]", "--json", "--compact"]),
("describe", "describe [OPTIONS] <ID>", &["<ID>", "--compact"]),
(
"app",
+5 -1
View File
@@ -27,9 +27,13 @@ serde_json.workspace = true
log.workspace = true
chrono.workspace = true
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Preserve X11 fallback while enabling font-free Wayland client-side controls.
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true
+3 -2
View File
@@ -33,6 +33,7 @@ impl NativeMenu {
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, "Preferences...", "", Some("setup.preferences".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())?;
@@ -94,8 +95,8 @@ impl NativeMenu {
&& 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).
if entry.path.is_empty() && (command.starts_with("window.") || command == "setup.preferences") {
// Application-menu window entries open their window rather than toggling it.
let _ = app.run(command, serde_json::json!({"value": true}));
} else {
menus::invoke_menu(app, command, &entry.path);
+9
View File
@@ -110,6 +110,15 @@ fn zero_channels_is_malformed() {
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
}
#[test]
fn aiff_negative_channel_count_is_malformed() {
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
aiff[comm + 8] = 0x80;
aiff[comm + 9] = 0;
assert!(probe(&aiff, None).is_err());
}
#[test]
fn zero_sample_rate_is_malformed() {
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
+1
View File
@@ -27,6 +27,7 @@ use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_p
use clap_sys::ext::params::{
CLAP_EXT_PARAMS, clap_host_params, clap_param_clear_flags, clap_param_info, clap_param_rescan_flags, clap_plugin_params,
};
use clap_sys::ext::render::{CLAP_EXT_RENDER, CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render};
use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state, clap_plugin_state};
use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory};
use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR};
+15 -2
View File
@@ -7,6 +7,7 @@ use super::{
clap_host, clap_plugin_audio_ports, clap_plugin_entry, clap_plugin_factory, clap_plugin_gui, clap_plugin_latency, clap_plugin_note_ports,
clap_plugin_params, clap_plugin_state, clap_version_is_compatible, read_cstr, scan,
};
use super::{CLAP_EXT_RENDER, clap_plugin_render};
// ---- loaded binaries -----------------------------------------------------------------------
@@ -42,6 +43,16 @@ fn loaded() -> &'static Mutex<HashMap<PathBuf, Arc<Bundle>>> {
L.get_or_init(|| Mutex::new(HashMap::new()))
}
/// A libloading error with the system's reason. libloading's own text is only "dlopen failed"
/// (or "dlsym failed", "LoadLibraryExW failed"); the reason (`dlerror`, the Windows error) is its
/// source.
fn with_reason(e: &libloading::Error) -> String {
match std::error::Error::source(e) {
Some(why) => format!("{e}: {why}"),
None => e.to_string(),
}
}
impl Bundle {
/// Loads (once per path, then cached for the life of the process) and initialises a `.clap`.
pub fn load(path: &Path) -> Result<Arc<Bundle>, ClapError> {
@@ -59,12 +70,12 @@ impl Bundle {
let exe = scan::bundle_executable(path).ok_or_else(|| err("no loadable binary".into()))?;
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
// hosting plugins; we only load files found in CLAP plugin folders or named explicitly.
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(e.to_string()))?;
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(with_reason(&e)))?;
// SAFETY: `clap_entry` is the CLAP-mandated exported data symbol of type
// `const clap_plugin_entry`; the symbol's address is therefore a `*const clap_plugin_entry`.
let entry: *const clap_plugin_entry = match unsafe { lib.get::<*const clap_plugin_entry>(b"clap_entry\0") } {
Ok(sym) => *sym,
Err(e) => return Err(err(format!("not a CLAP plugin: {e}"))),
Err(e) => return Err(err(format!("not a CLAP plugin (no clap_entry): {}", with_reason(&e)))),
};
if entry.is_null() {
return Err(err("null clap_entry".into()));
@@ -178,6 +189,7 @@ impl Bundle {
latency: std::ptr::null(),
notes: std::ptr::null(),
state_ext: std::ptr::null(),
render: std::ptr::null(),
gui: Arc::new(GuiLink::default()),
raw: Vec::with_capacity(EVENT_CAP),
out_params: Vec::with_capacity(EVENT_CAP),
@@ -205,6 +217,7 @@ impl Bundle {
inst.latency = inst.extension::<clap_plugin_latency>(CLAP_EXT_LATENCY);
inst.notes = inst.extension::<clap_plugin_note_ports>(CLAP_EXT_NOTE_PORTS);
inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
inst.render = inst.extension::<clap_plugin_render>(CLAP_EXT_RENDER);
let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
inst.gui = Arc::new(GuiLink {
inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),
+3 -1
View File
@@ -17,9 +17,11 @@ pub(crate) fn on_main_thread() -> bool {
unsafe { pthread_main_np() == 1 }
}
/// True on the process's main thread: Rust names it "main", and the app runs its UI there (the
/// rule `hostwin` and the VST3 host use). CLAP makes `clap.gui` main-thread only on every platform.
#[cfg(not(target_os = "macos"))]
pub(crate) fn on_main_thread() -> bool {
true
std::thread::current().name() == Some("main")
}
/// The windowing API of this platform.
+12
View File
@@ -8,6 +8,7 @@ use super::{
clap_plugin, clap_plugin_audio_ports, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_process,
read_name,
};
use super::{CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render};
// ---- audio buffers -------------------------------------------------------------------------
@@ -99,6 +100,7 @@ pub(crate) struct Instance {
pub(super) latency: *const clap_plugin_latency,
pub(super) notes: *const clap_plugin_note_ports,
pub(super) state_ext: *const clap_plugin_state,
pub(super) render: *const clap_plugin_render,
pub(super) gui: Arc<GuiLink>,
pub(super) raw: Vec<RawEvent>,
/// Parameter values the plugin reported through output events in the current `process`.
@@ -158,6 +160,16 @@ impl Instance {
unsafe { load(self.plugin, &stream) }
}
/// `clap_plugin_render.set`: offline (no realtime pressure) or realtime. `false` without the
/// extension or when the plugin can't switch.
pub fn set_render_mode(&self, offline: bool) -> bool {
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
let Some(set) = (unsafe { self.render.as_ref() }).and_then(|r| r.set) else { return false };
let mode = if offline { CLAP_RENDER_OFFLINE } else { CLAP_RENDER_REALTIME };
// SAFETY: valid instance, called from the thread that owns it.
unsafe { set(self.plugin, mode) }
}
/// Runs `on_main_thread` if the plugin asked for a callback.
pub fn service_callback(&mut self) {
if self.state.callback.swap(false, Ordering::Relaxed)
+38 -2
View File
@@ -222,10 +222,33 @@ mod tests {
assert!(plugin_info("reverb").is_none());
}
/// A new directory that this test alone created, readable only by its user on Unix. Creation
/// fails if the path already exists: the test then loads native libraries from it, so it must
/// never reuse a directory someone else could have put a library in. It lives next to the test
/// binary (in the build directory), not under the temp dir: `TMPDIR`/`TEMP` can name a shared
/// directory without a sticky bit or with inheritable write access, where another user could
/// rename or populate it. Anyone who can write to the build directory can already replace the
/// test binary itself, so this parent adds no new trust.
fn fresh_private_dir(prefix: &str) -> std::path::PathBuf {
let exe = std::env::current_exe().expect("test binary path");
let parent = exe.parent().expect("test binary has a parent directory");
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |t| t.as_nanos());
let d = parent.join(format!("{prefix}-{}-{nanos}", std::process::id()));
#[cfg(unix)]
let builder = {
let mut b = std::fs::DirBuilder::new();
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
b
};
#[cfg(not(unix))]
let builder = std::fs::DirBuilder::new();
builder.create(&d).unwrap_or_else(|e| panic!("cannot create a fresh test directory {}: {e}", d.display()));
d
}
#[test]
fn garbage_files_are_errors_not_crashes() {
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let d = fresh_private_dir("soundcraft-clap-garbage");
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
std::fs::write(d.join("empty.clap"), b"").unwrap();
@@ -239,4 +262,17 @@ mod tests {
assert!(instantiate("clap:does.not.exist").is_err());
let _ = std::fs::remove_dir_all(&d);
}
/// A binary the system refuses to load reports the system's reason (missing library, wrong
/// glibc, not an ELF file), not just "dlopen failed".
#[test]
fn a_binary_that_does_not_load_says_why() {
let d = fresh_private_dir("soundcraft-clap-reason");
let f = d.join("garbage.clap");
std::fs::write(&f, b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
let msg = load_bundle(&f).err().map(|e| e.to_string()).unwrap_or_default();
let reason = msg.split_once("failed: ").map_or("", |(_, r)| r.trim());
assert!(!reason.is_empty(), "no reason in: {msg}");
let _ = std::fs::remove_dir_all(&d);
}
}
+5
View File
@@ -178,6 +178,11 @@ impl Plugin for ClapPlugin {
}
}
fn set_offline(&mut self, offline: bool) {
// `clap.render`: plugins without it render the same way either way.
self.inst.set_render_mode(offline);
}
fn reset(&mut self) {
self.inst.reset();
self.queue.retain(|e| matches!(e, Event::Param { .. }));
+3 -1
View File
@@ -150,7 +150,9 @@ mod tests {
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("soundcraft-clap-scan-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
// Created exclusively: a folder still there after the cleanup (someone else's) would
// let a link inside it redirect the test's writes.
std::fs::create_dir(&d).unwrap();
d
}
@@ -5,6 +5,7 @@
mod common;
use soundcraft_dsp::Plugin;
use std::sync::Mutex;
const GAIN: &str = "clap:org.soundcraft.test.gain";
@@ -14,12 +15,31 @@ fn block(p: &mut Box<dyn Plugin>) -> Vec<Vec<f32>> {
io
}
/// The warnings logged, to tell which way the host went.
static WARNINGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
struct Warnings;
impl log::Log for Warnings {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn
}
fn log(&self, r: &log::Record) {
if self.enabled(r.metadata()) {
WARNINGS.lock().unwrap().push(r.args().to_string());
}
}
fn flush(&self) {}
}
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("editor_main_thread: test");
return;
}
log::set_logger(&Warnings).expect("logger");
log::set_max_level(log::LevelFilter::Warn);
common::fixture_dir();
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
p.prepare(48_000.0, 64, 2);
@@ -32,6 +52,26 @@ fn main() {
// block and is reported (in SoundCraft units) by idle.
let io = block(&mut p);
assert!((io[0][10] - 0.25).abs() < 1e-6, "{}", io[0][10]);
// Only the main thread drives the editor, on every platform (#901). Another thread, named or
// not, is refused: it neither takes the edit idle reports nor closes or reopens the editor.
for name in [None, Some("soundcraft-ui")] {
let mut other = p.editor().expect("editor handle");
let thread = match name {
Some(n) => std::thread::Builder::new().name(n.to_string()),
None => std::thread::Builder::new(),
};
let seen = thread
.spawn(move || {
let edits = other.idle();
other.close();
(edits, other.open().map_err(|e| e.contains("main thread")))
})
.expect("spawn")
.join()
.expect("no panic");
assert_eq!(seen, (Vec::new(), Err(true)), "{name:?}");
assert!(ed.is_open(), "{name:?}");
}
assert_eq!(ed.idle(), vec![("7".to_string(), 0.25)]);
assert!(ed.idle().is_empty(), "drained");
ed.close();
@@ -49,5 +89,17 @@ fn main() {
assert!(ed.open().is_err());
assert!(ed.idle().is_empty());
ed.close();
// #901: a plugin dropped on another thread with its editor open (an audio callback dropped
// by the device's thread) does not destroy the editor there, clap.gui being main-thread only:
// it warns and leaves it.
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
p.prepare(48_000.0, 64, 2);
let mut ed = p.editor().expect("editor handle");
ed.open().expect("open on the main thread");
std::thread::Builder::new().name("audio".into()).spawn(move || drop(p)).expect("spawn").join().expect("no panic");
let left = "clap: editor still open while its plugin is destroyed off the main thread";
assert!(WARNINGS.lock().unwrap().iter().any(|w| w == left), "{:?}", WARNINGS.lock().unwrap());
assert!(!ed.is_open());
assert!(ed.open().is_err());
println!("editor_main_thread: ok");
}
+31 -6
View File
@@ -9,7 +9,8 @@
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
//! `process` writes 1.0 into every output channel it was given when the host's buffers match the
//! advertised layout, -1.0 when they do not.
//! advertised layout (2.0 once the host set the offline render mode through `clap.render`), -1.0
//! when they do not.
//!
//! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a
//! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI,
@@ -27,6 +28,7 @@ use clap_sys::ext::latency::*;
use clap_sys::ext::log::*;
use clap_sys::ext::note_ports::*;
use clap_sys::ext::params::*;
use clap_sys::ext::render::*;
use clap_sys::factory::plugin_factory::*;
use clap_sys::host::clap_host;
use clap_sys::id::clap_id;
@@ -100,8 +102,12 @@ struct Fx {
active: bool,
processing: bool,
gui_created: bool,
/// The thread that created the GUI (the host's main thread).
gui_thread: Option<std::thread::ThreadId>,
gui_visible: bool,
gui_edit_pending: bool,
/// `clap.render` set the offline mode (wide fixtures only).
offline: bool,
}
unsafe fn fx<'a>(p: *const clap_plugin) -> &'a mut Fx {
@@ -126,7 +132,10 @@ unsafe extern "C" fn init(p: *const clap_plugin) -> bool {
}
unsafe extern "C" fn destroy(p: *const clap_plugin) {
assert!(!unsafe { fx(p) }.gui_created, "plugin destroyed with its GUI alive");
let f = unsafe { fx(p) };
// A host destroying the plugin off the GUI's thread can't destroy the GUI there (clap.gui is
// main-thread only), so it may leave it; on the GUI's thread it must destroy it first.
assert!(!f.gui_created || f.gui_thread != Some(std::thread::current().id()), "plugin destroyed with its GUI alive");
let data = unsafe { (*p).plugin_data } as *mut Fx;
drop(unsafe { Box::from_raw(data) });
}
@@ -189,8 +198,8 @@ fn apply(f: &mut Fx, h: &clap_event_header) {
}
}
/// Fill every output channel with 1.0 when all ports have the wide layout, else -1.0.
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
/// Fill every output channel with `fit` when all ports have the wide layout, else -1.0.
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize, fit: f32) {
let fits = |bufs: *const clap_audio_buffer, n: u32| {
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
};
@@ -200,7 +209,7 @@ unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
for c in 0..out.channel_count as usize {
let o = unsafe { *out.data32.add(c) };
for i in 0..n {
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
unsafe { *o.add(i) = if ok { fit } else { -1.0 } };
}
}
}
@@ -252,7 +261,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
let ev = unsafe { &*pr.in_events };
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
if let Some((ports, channels)) = f.wide {
unsafe { fill_wide(pr, ports, channels, n) };
unsafe { fill_wide(pr, ports, channels, n, if f.offline { 2.0 } else { 1.0 }) };
return CLAP_PROCESS_CONTINUE;
}
if pr.audio_outputs_count != 1 {
@@ -488,6 +497,7 @@ unsafe extern "C" fn gui_create(p: *const clap_plugin, _api: *const c_char, floa
let f = unsafe { fx(p) };
assert!(floating && !f.gui_created, "create");
f.gui_created = true;
f.gui_thread = Some(std::thread::current().id());
true
}
@@ -543,6 +553,17 @@ static GUI: clap_plugin_gui = clap_plugin_gui {
hide: Some(gui_hide),
};
unsafe extern "C" fn render_hard_realtime(_p: *const clap_plugin) -> bool {
false
}
unsafe extern "C" fn render_set(p: *const clap_plugin, mode: clap_plugin_render_mode) -> bool {
unsafe { fx(p) }.offline = mode == CLAP_RENDER_OFFLINE;
true
}
static RENDER: clap_plugin_render = clap_plugin_render { has_hard_realtime_requirement: Some(render_hard_realtime), set: Some(render_set) };
unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) -> *const c_void {
let id = unsafe { CStr::from_ptr(id) };
let synth = unsafe { fx(p) }.synth;
@@ -558,6 +579,8 @@ unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) ->
&STATE as *const _ as *const c_void
} else if id == CLAP_EXT_GUI && !synth {
&GUI as *const _ as *const c_void
} else if id == CLAP_EXT_RENDER && unsafe { fx(p) }.wide.is_some() {
&RENDER as *const _ as *const c_void
} else {
std::ptr::null()
}
@@ -621,8 +644,10 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
active: false,
processing: false,
gui_created: false,
gui_thread: None,
gui_visible: false,
gui_edit_pending: false,
offline: false,
});
let raw = Box::into_raw(b);
unsafe {
+17 -6
View File
@@ -130,6 +130,19 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
}
}
#[test]
fn offline_renders_set_the_render_mode() {
fixture_dir();
for (offline, want) in [(false, 1.0), (true, 2.0)] {
let mut p = soundcraft_clap_host::create("clap:org.soundcraft.test.wide16").unwrap();
p.set_offline(offline);
p.prepare(44_100.0, 64, 2);
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == want), "offline {offline}: {:?}", &io[0][..4]);
}
}
#[test]
fn many_instances_create_and_drop_cleanly() {
fixture_dir();
@@ -202,14 +215,12 @@ fn state_reader_reads_while_another_thread_processes() {
#[test]
fn editors_refuse_to_open_off_the_main_thread() {
fixture_dir();
// Test threads are never the process main thread.
// Test threads are never the process main thread, on any platform (#901).
let mut p = soundcraft_clap_host::create(GAIN).unwrap();
let mut ed = p.editor().expect("the gain fixture has clap.gui");
if cfg!(target_os = "macos") {
assert!(ed.open().unwrap_err().contains("main thread"));
assert!(p.open_editor().is_err());
assert!(!ed.is_open());
}
assert!(ed.open().unwrap_err().contains("main thread"));
assert!(p.open_editor().is_err());
assert!(!ed.is_open());
ed.close();
p.close_editor();
}
+5
View File
@@ -195,6 +195,11 @@ pub struct Transport {
pub trait Plugin: Send {
fn info(&self) -> &'static PluginInfo;
fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize);
/// The plugin renders offline (a bounce: faster or slower than realtime, no deadline) rather
/// than in realtime. Hosted plugins pass it on (VST3 `processMode`, CLAP `clap.render`): some
/// use better algorithms then, or wait for what they load instead of playing on without it.
/// Called before `prepare`; plugins are realtime until told otherwise.
fn set_offline(&mut self, _offline: bool) {}
fn reset(&mut self);
/// Sets a parameter (clamped into range; NaN becomes the default). `false` if `id` is unknown.
fn set_param(&mut self, id: &str, value: f32) -> bool;
+144 -11
View File
@@ -212,6 +212,11 @@ impl Autosave {
let audio_dir = self.dir.join("Audio Files");
let mut s = session.clone();
let mut copies = BTreeMap::new();
// Copies made in this write, by the audio they hold: sources sharing one decoded buffer
// (a session may name one file under many ids) share one file.
let mut written: BTreeMap<*const SourceAudio, String> = BTreeMap::new();
// Copies still to make, written once the size is checked; their numbers are taken then too.
let (mut fresh, mut next) = (Vec::new(), self.next);
for src in &mut s.sources {
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
let audio = s.pool.get(src.id);
@@ -222,26 +227,37 @@ impl Autosave {
continue;
}
let Some(audio) = audio else { continue };
let name = match self.copies.remove(&src.id) {
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
let name = match self.copies.get(&src.id) {
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name.clone(),
_ if written.contains_key(&Arc::as_ptr(audio)) => written.get(&Arc::as_ptr(audio)).cloned().unwrap_or_default(),
_ => {
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
self.next += 1;
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(&name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
let name = format!("{} {}.wav", sanitize_name(&src.name), next);
next += 1;
fresh.push((name.clone(), Arc::clone(audio)));
name
}
};
src.path = audio_dir.join(&name).to_string_lossy().into_owned();
src.unsaved = true;
written.insert(Arc::as_ptr(audio), name.clone());
copies.insert(src.id, (Arc::downgrade(audio), name));
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
// An autosave too large to recover is not written, nor its new copies: the last autosave
// stays as it was, and the failure is reported (#892).
crate::io::session_size("the autosave", text.len() as u64, true)?;
self.next = next;
for (name, audio) in &fresh {
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
}
write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?;
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
let _ = std::fs::remove_file(audio_dir.join(name));
if !self.copies.values().any(|(_, kept)| *kept == name) {
let _ = std::fs::remove_file(audio_dir.join(name));
}
}
if let Some(r) = recording {
let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?;
@@ -290,9 +306,7 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
drop(lock);
match saved {
Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {
let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default();
let named = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string));
let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
let name = saved_name(&dir.join(SESSION)).unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
out.push(Leftover { dir, name, origin, saved, recording });
}
_ => {
@@ -304,6 +318,21 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
out
}
/// The name of the session saved in `file`, read without keeping the rest of it; `None` when the
/// file is missing, not a session, or too large to open (#892).
fn saved_name(file: &Path) -> Option<String> {
use std::io::Read as _;
#[derive(serde::Deserialize)]
struct Named {
name: String,
}
if std::fs::metadata(file).ok()?.len() > crate::io::session_limit() {
return None;
}
let read = std::fs::File::open(file).ok()?.take(crate::io::session_limit());
serde_json::from_reader::<_, Named>(std::io::BufReader::new(read)).ok().map(|n| n.name)
}
/// Open `left` as an unsaved version of the session it came from, so Save writes it back there,
/// with the takes of a recording that never finished placed as Stop would have. Returns the
/// media that couldn't be loaded.
@@ -451,6 +480,27 @@ mod tests {
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
/// Recovery loads document edits without re-enabling live inputs from an autosave.
#[test]
fn recovered_live_input_states_start_disabled() {
let root = folder("live-input-recovery");
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let track = e.session().tracks[0].id;
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
let mut autosave = Autosave::create(&root, "Live inputs").unwrap();
autosave.write(e.session(), None, None).unwrap();
drop(autosave);
let mut recovered = Engine::default();
recover(&mut recovered, &leftovers(&root)[0]).unwrap();
let mixer = &recovered.session().track(track).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert_eq!(mixer.volume_db, -6.0);
std::fs::remove_dir_all(root).unwrap();
}
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
@@ -507,6 +557,25 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sources_sharing_one_buffer_share_one_copy() {
let dir = folder("autosave-aliases");
let mut e = Engine::default();
record(&mut e, 0.5);
// Nine more source ids over the same decoded audio, as a session file may declare.
let s = e.session_mut();
let (src, audio) = (s.sources[0].clone(), s.pool.get(s.sources[0].id).cloned().unwrap());
for _ in 0..9 {
let id = SourceId(s.alloc());
s.sources.push(soundcraft_model::Source { id, ..src.clone() });
s.pool.insert(id, audio.clone());
}
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
a.write(e.session(), None, None).unwrap();
assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1, "one file for the ten ids");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_take_recorded_after_undo_gets_its_own_copy() {
let dir = folder("autosave-undo");
@@ -673,6 +742,70 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A test folder, removed when the test ends, passed or not.
struct Gone(PathBuf);
impl Drop for Gone {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn an_autosave_too_large_to_open_writes_nothing_and_keeps_the_last() {
// #892, with the limit lowered for this test: a refused autosave makes no audio copies
// and keeps the last autosave and its copies as they were.
let dir = Gone(folder("autosave-too-large"));
let mut e = Engine::default();
record(&mut e, 0.5);
let mut a = Autosave::create(&dir.0.join("Autosave"), "Song").unwrap();
a.write(e.session(), None, None).unwrap();
let state = |a: &Autosave| {
let mut files: Vec<String> =
std::fs::read_dir(a.dir.join("Audio Files")).unwrap().map(|f| f.unwrap().file_name().to_string_lossy().into_owned()).collect();
files.sort();
let copies: Vec<(SourceId, String)> = a.copies.iter().map(|(id, (_, name))| (*id, name.clone())).collect();
(files, copies, a.next, std::fs::read_to_string(a.dir.join(SESSION)).unwrap())
};
let before = state(&a);
record(&mut e, 0.25);
{
let _limit = crate::io::SessionLimit::set(before.3.len() as u64);
for _ in 0..2 {
let err = a.write(e.session(), None, None).unwrap_err().to_string();
assert!(err.starts_with("the autosave is too large to save"), "{err}");
assert!(state(&a) == before);
}
// The writer thread reports it as it reports any failed autosave.
let failure = Arc::new(Mutex::new(None));
let pending = Some((e.session_arc(), None, None));
let mut w = Writer { root: dir.0.join("Writer"), failure: Arc::clone(&failure), folder: None, pending, last: None, written: Ok(()) };
w.write_pending();
let reported = failure.lock().unwrap().take().unwrap_or_default();
assert!(reported.starts_with("the autosave is too large to save"), "{reported}");
}
a.write(e.session(), None, None).unwrap();
let after = state(&a);
assert_eq!((after.0.len(), after.1.len()), (2, 2));
assert!(after.0.contains(&before.0[0]));
}
#[test]
fn a_leftover_is_named_from_its_session_without_reading_a_huge_one() {
// #892: the name is read on its own, and an autosave too large to open is not read.
let dir = Gone(folder("autosave-names"));
std::fs::create_dir_all(&dir.0).unwrap();
let file = dir.0.join(SESSION);
let s = Session { name: "Song".into(), ..Session::default() };
std::fs::write(&file, s.to_json().unwrap()).unwrap();
assert_eq!(saved_name(&file).as_deref(), Some("Song"));
std::fs::write(&file, r#"{"name": 5}"#).unwrap();
assert_eq!(saved_name(&file), None);
std::fs::File::create(&file).unwrap().set_len(crate::io::MAX_SESSION_BYTES + 1).unwrap();
assert_eq!(saved_name(&file), None);
assert_eq!(saved_name(&dir.0.join("none")), None);
}
#[test]
fn sessions_started_together_get_folders_of_their_own() {
// #579: the folder name has millisecond resolution, so two sessions created back to back
+4 -1
View File
@@ -347,7 +347,10 @@ pub fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();
// Each clip once, in the order given: a repeated id would otherwise be processed (and,
// for a render, re-rendered into another source) once per repeat.
let mut seen = std::collections::HashSet::new();
return arr.iter().filter_map(Value::as_u64).map(ClipId).filter(|id| seen.insert(*id)).collect();
}
if let Some(n) = p.get("clip").and_then(Value::as_u64) {
return vec![ClipId(n)];
+15 -1
View File
@@ -14,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AudioSuite Process",
[],
None,
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
"{process: <built-in plugin id (engine.plugins)> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
has_selection,
process
)]
@@ -55,6 +55,9 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
| "speed_ramp"
| "dc_offset_removal"
);
if !known && soundcraft_mix::is_third_party(&name) {
return Err(bad("audiosuite.process", format!("`{name}` is a hosted plugin; AudioSuite runs built-in plugins only (engine.plugins)")));
}
if !known {
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
}
@@ -228,6 +231,17 @@ mod tests {
(crate::Engine::new(s), id)
}
#[test]
fn hosted_plugin_ids_are_refused_with_the_reason() {
let (mut e, id) = gained_clip();
for plugin in ["clap:com.example.reverb", "vst3:00112233445566778899AABBCCDDEEFF", "au:aufx:dely:appl"] {
let err = e.execute("audiosuite.process", &json!({"process": plugin, "clips": [id.0]})).unwrap_err().to_string();
assert!(err.contains("built-in"), "{plugin}: {err}");
}
let spec = specs().into_iter().find(|s| s.id == "audiosuite.process").unwrap();
assert!(spec.params.contains("<built-in plugin id"), "the help says which ids it takes: {}", spec.params);
}
#[test]
fn invert_and_duplicate_keep_clip_gain() {
let (mut e, id) = gained_clip();
+1 -1
View File
@@ -425,7 +425,7 @@ fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session();
let moves: Vec<(soundcraft_model::ClipId, i64)> =
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect();
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false, &ids)))).collect();
let s = e.session_mut();
for (id, d) in &moves {
crate::edit::move_clips(s, &[*id], *d, &[]);
+19 -6
View File
@@ -301,6 +301,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
cut_track(s, *t, range, sh, follow);
}
s.edit.selection = soundcraft_time::Range::point(range.start);
cb.cut_from = Some(range.start);
}
let n: usize = cb.tracks.iter().map(Vec::len).sum();
e.clipboard = cb;
@@ -328,6 +329,7 @@ pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
cb
}
/// Pastes the clipboard at an explicit position or at the current edit insertion point.
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.paste", p)?;
let given = position_param(e, "edit.paste", p, "at")?;
@@ -335,9 +337,10 @@ fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session();
let at = match given {
Some(at) => at,
None if s.edit.edit_mode == EditMode::GridRelative => {
s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset)
None if s.edit.edit_mode == EditMode::GridRelative && s.edit.selection.is_empty() && cb.cut_from == Some(s.edit.selection.start) => {
s.edit.selection.start
}
None if s.edit.edit_mode == EditMode::GridRelative => s.grid_line_at_or_before(s.edit.selection.start).saturating_add(cb.grid_offset),
None => s.edit.selection.start,
};
let sh = shuffle(s) && !cb.automation_only;
@@ -668,11 +671,16 @@ fn separate(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"separated": n}))
}
/// Separates selected clips at every active Grid boundary in the requested range.
fn separate_on_grid(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.separate_on_grid", p)?;
let r = range_param(e, "edit.separate_on_grid", p)?;
let s = e.session_mut();
let lines = s.edit.grid.lines(r.start, r.end, s.sample_rate, &s.tempo, s.frame_rate, 10_000);
let max = if s.edit.grid_clips_markers { usize::MAX } else { 10_001 };
let lines = s.grid_lines(r.start, r.end, max);
if lines.len() > 10_000 {
return Err(bad("edit.separate_on_grid", "the range contains more than 10,000 grid boundaries"));
}
let mut n = 0;
for t in &tracks {
for at in &lines {
@@ -956,6 +964,7 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"moved": n, "by": step}))
}
/// Same-track placement at the existing start is a no-op; quantization is a separate command.
fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
if ids.is_empty() {
@@ -978,17 +987,21 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
};
let mode = s.edit.edit_mode;
let delta = match first {
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative),
Some(_) if mode.is_grid() && p.get("to").is_some() && delta == 0 && dest.iter().all(|(from, to)| from == to) => 0,
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative, &ids),
_ => delta,
};
let effective_delta = first.map_or(delta, |clip| delta.max(clip.start.saturating_neg()));
let s = e.session_mut();
let n = if mode == EditMode::Shuffle {
ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))?
} else if effective_delta == 0 && dest.iter().all(|(from, to)| from == to) {
0
} else {
ops::move_clips(s, &ids, delta, &dest)
ops::move_clips(s, &ids, effective_delta, &dest)
};
s.edit.selected_clips = ids;
Ok(json!({"moved": n, "by": delta}))
Ok(json!({"moved": n, "by": if mode == EditMode::Shuffle { delta } else { effective_delta }}))
}
fn trim_clip(e: &mut Engine, p: &Value) -> Result<Value> {
+2
View File
@@ -45,6 +45,8 @@ pub fn specs() -> Vec<CommandSpec> {
s.sources.clear();
s.videos.clear();
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
// A template too large to open is not saved (#892).
crate::io::session_size(file.display(), text.len() as u64, true)?;
if cfg!(target_arch = "wasm32") {
crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?;
} else {
+71 -17
View File
@@ -7,6 +7,7 @@ use crate::{Engine, EngineError, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
/// Commands for mixer controls, with live input toggles kept outside document history.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("mix.volume", "Set Volume", [], None, "{tracks?, db: -144..12 | delta_db}", has_selection, volume),
@@ -42,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(json!({}))
}),
cmd!("mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
cmd!(noundo "mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
e,
p,
"mix.record_arm",
@@ -51,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
|tr| tr.mixer.record_arm
)),
cmd!("mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
cmd!(noundo "mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
e,
p,
"mix.input_monitor",
@@ -218,6 +219,23 @@ fn toggle(e: &mut Engine, p: &Value, cmd: &str, set: fn(&mut Session, TrackId, b
Ok(json!({"value": v}))
}
/// Toggle live input flags without marking the saved document as changed.
fn toggle_runtime(
e: &mut Engine,
p: &Value,
cmd: &str,
set: fn(&mut Session, TrackId, bool),
get: fn(&soundcraft_model::Track) -> bool,
) -> Result<Value> {
let tracks = tracks_required(e, cmd, p)?;
let s = e.runtime_session_mut();
let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(get)));
for t in &tracks {
set(s, *t, v);
}
Ok(json!({"value": v}))
}
fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "mix.volume", p)?;
let abs = p.get("db").and_then(Value::as_f64);
@@ -598,6 +616,7 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result<Value> {
}
/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds.
#[cfg(test)]
fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
let mut prev: Option<(i64, f32)> = None;
for &(t, v) in env {
@@ -612,6 +631,38 @@ fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
prev.map_or(0.0, |(_, v)| v)
}
/// Repeated lookups in one clip-gain envelope, each O(log n) instead of a walk from its start.
/// Gives exactly what walking the points in stored order gives, sorted or not: the first point
/// at or after `at` is the first whose running maximum position reaches `at`.
struct EnvLookup<'a> {
env: &'a [(i64, f32)],
/// Largest position among `env[..=i]`.
reach: Vec<i64>,
}
impl<'a> EnvLookup<'a> {
fn new(env: &'a [(i64, f32)]) -> Self {
let reach = env
.iter()
.scan(i64::MIN, |m, &(t, _)| {
*m = (*m).max(t);
Some(*m)
})
.collect();
EnvLookup { env, reach }
}
/// Value in dB at `at` (see `env_db`).
fn db(&self, at: i64) -> f32 {
let i = self.reach.partition_point(|&m| m < at);
let Some(&(t, v)) = self.env.get(i) else { return self.env.last().map_or(0.0, |&(_, v)| v) };
match i.checked_sub(1).and_then(|p| self.env.get(p)) {
Some(&(pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32),
_ => v,
}
}
}
/// Fold the volume `lane` into the gain envelope of clip `c` over the selection `r`.
fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) {
let delta = |at: i64| lane.value_at(at, base) - base;
@@ -621,39 +672,41 @@ fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model
return;
}
let rel = |at: i64| at - c.start;
let old_env = std::mem::take(&mut c.gain_env);
let env = EnvLookup::new(&old_env);
let env_db = |at: i64| env.db(at);
let rel0 = rel(sel0);
let rel1 = rel(sel1);
// Later writes win, so the selection anchors replace any point they land on.
let mut pts: Vec<(i64, f32)> =
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
let mut pts: Vec<(i64, f32)> = old_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
if rel0 > 0 {
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
pts.push((rel0 - 1, env_db(rel0 - 1)));
}
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
pts.push((rel0, env_db(rel0) + delta(sel0)));
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
let at = rel(pt.at);
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
pts.push((at, env_db(at) + delta(pt.at)));
}
// The clip's own gain points inside the selection keep their shape, offset by the lane.
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
for &(at, _) in old_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
pts.push((at, env_db(at) + delta(c.start.saturating_add(at))));
}
if sel1 - 1 > sel0 {
let at = rel(sel1 - 1);
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
pts.push((at, env_db(at) + delta(sel1 - 1)));
}
if sel1 < c.end() {
pts.push((rel1, env_db(&c.gain_env, rel1)));
pts.push((rel1, env_db(rel1)));
}
pts.sort_by_key(|p| p.0);
let mut env = Vec::new();
let mut baked = Vec::new();
for p in pts {
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
env.pop();
if baked.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
baked.pop();
}
env.push(p);
baked.push(p);
}
c.gain_env = env;
c.gain_env = baked;
}
/// Clear the volume lane over `r` when the clips all sit inside it, else hold the fader there.
@@ -726,8 +779,9 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
if let Some(l) = &old {
ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1));
}
let env = EnvLookup::new(&c.gain_env);
for at in ats {
pts.push((at, level(at) + c.gain_db + env_db(&c.gain_env, at - c.start)));
pts.push((at, level(at) + c.gain_db + env.db(at - c.start)));
}
c.gain_db = 0.0;
c.gain_env.clear();
+139
View File
@@ -345,3 +345,142 @@ fn insert_and_send_reject_invalid_slot() {
let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
assert_eq!(r["slot"], 0);
}
#[test]
fn indexed_envelope_lookup_matches_the_walk() {
let envs: [Vec<(i64, f32)>; 5] = [
Vec::new(),
vec![(100, -3.0)],
vec![(0, 0.0), (100, -6.0), (100, 3.0), (250, -12.0), (900, 1.0)],
// Out of order, as a hand-edited session file may hold.
vec![(500, -1.0), (100, -6.0), (700, 4.0), (300, 2.0), (700, -9.0), (50, 0.5)],
(0..200).map(|i| ((i * 37) % 1000, (i as f32 * 0.7).sin() * 12.0)).collect(),
];
for env in &envs {
let lookup = EnvLookup::new(env);
for at in -20..1100 {
assert_eq!(lookup.db(at).to_bits(), env_db(env, at).to_bits(), "{env:?} at {at}");
}
}
}
#[test]
fn clip_gain_to_volume_with_a_large_envelope_stays_fast() {
let (mut e, t) = tone_session(&[]);
let clip = e.session().track(t).unwrap().clips()[0].id;
let n = 200_000i64;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.length = n * 5 + 10;
c.gain_env = (0..n).map(|i| (i * 5, if i % 2 == 0 { -6.0 } else { 0.0 })).collect();
let start = std::time::Instant::now();
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": n * 5 + 10})).unwrap();
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
assert!(lane.points.len() as i64 >= n);
}
/// Live input toggles stay outside document history in both enabled and disabled states.
#[test]
fn live_input_states_are_transient_and_survive_document_history() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let track = e.session().tracks[0].id;
e.mark_clean();
let history = e.undo_history();
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
e.execute("track.set_record_to_input_only", &json!({})).unwrap();
let mixer = &e.session().track(track).unwrap().mixer;
assert!(mixer.record_arm && mixer.input_monitor);
assert!(!e.is_dirty(), "live input state is not a document change");
assert_eq!(e.undo_history(), history, "live input state has no Undo entry");
// A later document edit captures a full Session snapshot. Undoing or redoing it must not
// resurrect the live state that happened to be present when that snapshot was taken.
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": false})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": false})).unwrap();
assert!(e.is_dirty());
assert!(e.undo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(!e.is_dirty(), "undo returned to the saved document");
assert!(e.redo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(e.is_dirty());
// Live states that are enabled after another document edit survive both directions too.
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -12.0})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();
assert!(e.undo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(mixer.record_arm && mixer.input_monitor);
assert!(e.redo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(mixer.record_arm && mixer.input_monitor);
}
/// Returning to a saved point stays clean with independently enabled live flags on each track.
#[test]
fn live_input_states_keep_saved_history_points_clean() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 2})).unwrap();
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
e.mark_clean();
e.execute("mix.volume", &json!({"tracks": [a.0], "db": -6.0})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
assert!(e.undo());
assert!(!e.is_dirty(), "live flags cannot dirty the saved point reached by Undo");
for redo in [true, false, true] {
assert!(if redo { e.redo() } else { e.undo() });
let a = &e.session().track(a).unwrap().mixer;
let b = &e.session().track(b).unwrap().mixer;
assert!(a.record_arm && !a.input_monitor);
assert!(!b.record_arm && b.input_monitor);
assert_eq!(e.is_dirty(), redo);
}
e.mark_clean();
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": false})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": false})).unwrap();
assert!(!e.is_dirty());
assert!(e.undo());
assert!(e.is_dirty());
assert!(e.redo());
assert!(!e.is_dirty(), "Redo to a saved armed snapshot stays clean after disarming");
}
/// Restoring a missing track never resurrects its historical arm or monitoring flags.
#[test]
fn live_input_states_of_restored_tracks_start_disabled() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 2})).unwrap();
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
e.execute("track.delete", &json!({"tracks": [a.0]})).unwrap();
assert!(e.undo());
let mixer = &e.session().track(a).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(e.session().track(b).unwrap().mixer.input_monitor);
assert!(e.redo());
assert!(e.undo());
assert!(!e.session().track(a).unwrap().mixer.record_arm);
e.execute("track.new", &json!({})).unwrap();
let c = e.session().tracks.last().unwrap().id;
e.execute("mix.record_arm", &json!({"tracks": [c.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [c.0], "value": true})).unwrap();
assert!(e.undo());
assert!(e.session().track(c).is_none());
assert!(e.redo());
let mixer = &e.session().track(c).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(e.session().track(b).unwrap().mixer.input_monitor);
}
+1 -1
View File
@@ -472,7 +472,7 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "file.import_session_data";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string();
let text_v = crate::io::read_text(&path)?;
let text_v = crate::io::read_session_text(&path)?;
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let names: Option<Vec<String>> =
p.get("tracks").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
+35 -5
View File
@@ -7,6 +7,7 @@ use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{ChannelFormat, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
/// Returns the track command specifications exposed to UI and automation clients.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -14,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New...",
["Track"],
Some("Cmd+Shift+N"),
"{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id}",
"{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id, rows?: [objects with the same fields] (1–128 tracks total)}",
always,
new_track
),
@@ -99,8 +100,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("track.bounce", "Bounce...", ["Track"], Some("Cmd+Alt+Shift+B"), "{tracks?}", has_selection, commit),
cmd!("track.bypass_inserts", "All", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, bypass_inserts),
cmd!("track.mute_sends", "All", ["Track", "Mute Sends"], None, "{tracks?, mute?: bool}", has_selection, mute_sends),
cmd!("track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
let s = e.session_mut();
cmd!(noundo "track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
let s = e.runtime_session_mut();
let mut n = 0;
for t in &mut s.tracks {
if t.mixer.record_arm {
@@ -231,7 +232,37 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Creates either one legacy row or several validated rows as one command.
fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = if let Some(rows) = p.get("rows") {
let rows = rows.as_array().ok_or_else(|| bad("track.new", "`rows` must be an array"))?;
if rows.is_empty() || rows.len() > 128 {
return Err(bad("track.new", "`rows` must contain 1–128 rows"));
}
let mut total = 0_i64;
for row in rows {
if !row.is_object() || row.get("rows").is_some() {
return Err(bad("track.new", "each row must be an object without nested rows"));
}
total = total.saturating_add(i64_or(row, "count", 1).clamp(1, 128));
}
if total > 128 {
return Err(bad("track.new", "a single creation is limited to 128 tracks"));
}
let mut ids = Vec::new();
for row in rows {
ids.extend(new_track_row(e, row)?);
}
ids
} else {
new_track_row(e, p)?
};
e.session_mut().edit.selected_tracks = ids.clone();
Ok(json!({"tracks": ids}))
}
/// All rows run inside one command, so failures roll back and Undo covers the whole creation.
fn new_track_row(e: &mut Engine, p: &Value) -> Result<Vec<TrackId>> {
let count = i64_or(p, "count", 1).clamp(1, 128);
let kind = match str_param(p, "kind") {
Some(k) => TrackKind::from_id(k).ok_or_else(|| bad("track.new", format!("unknown kind `{k}`")))?,
@@ -271,8 +302,7 @@ fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
}
ids.push(id);
}
s.edit.selected_tracks = ids.clone();
Ok(json!({"tracks": ids}))
Ok(ids)
}
fn group(e: &mut Engine, p: &Value) -> Result<Value> {
+11 -4
View File
@@ -267,6 +267,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"path": path, "track": first}))
}
/// Load persistent mixer settings while retaining destination routing and live input flags.
fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "track.load_preset";
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
@@ -283,15 +284,17 @@ fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
// Routing is session-specific: keep the track's own input/output, VCA and arm state.
let keep = (tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.pan.clone());
// Routing and live input state are track-specific: a preset must not replace them.
let keep =
(tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.input_monitor, tr.mixer.pan.clone());
tr.mixer = mixer.clone();
tr.mixer.input = keep.0;
tr.mixer.output = keep.1;
tr.mixer.vca = keep.2;
tr.mixer.record_arm = keep.3;
if tr.mixer.pan.len() != keep.4.len() {
tr.mixer.pan = keep.4;
tr.mixer.input_monitor = keep.4;
if tr.mixer.pan.len() != keep.5.len() {
tr.mixer.pan = keep.5;
}
if tr.kind == TrackKind::Instrument && instrument.is_some() {
tr.instrument = instrument.clone();
@@ -602,6 +605,7 @@ mod tests {
assert_eq!(e.session().track(pad).unwrap().folder, Some(verb));
}
/// A preset transfers persistent mix settings without replacing live destination states.
#[test]
fn preset_round_trip() {
let dir = std::env::temp_dir().join(format!("sc-preset-{}", std::process::id()));
@@ -611,10 +615,13 @@ mod tests {
e.execute("edit.select", &json!({"tracks": ["Bass"]})).unwrap();
e.execute("track.save_preset", &json!({"path": path})).unwrap();
e.execute("edit.select", &json!({"tracks": ["Hats"]})).unwrap();
e.execute("mix.record_arm", &json!({"value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"value": true})).unwrap();
e.execute("track.load_preset", &json!({"path": path})).unwrap();
let hats = e.session().track_by_name("Hats").unwrap();
assert_eq!(hats.mixer.inserts[0].as_ref().map(|i| i.plugin.as_str()), Some("compressor"));
assert_eq!(hats.mixer.volume_db, -5.0);
assert!(hats.mixer.record_arm && hats.mixer.input_monitor, "loading a preset keeps live input state");
let _ = std::fs::remove_dir_all(&dir);
}
+186 -14
View File
@@ -3,9 +3,9 @@ use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_time::TimeFormat;
use soundcraft_time::{GridValue, MAX_POSITION, NoteValue};
use soundcraft_time::{FeetFramesValue, GridScale, GridValue, MAX_POSITION, NoteValue, TimeFormat, TimecodeValue};
/// Returns commands that control saved view, counter, Grid, and Nudge state.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
@@ -65,7 +65,18 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
let f = str_param(p, "format").and_then(TimeFormat::from_id).ok_or_else(|| bad("view.main_counter", "unknown format"))?;
e.session_mut().edit.main_counter = f;
let edit = &mut e.session_mut().edit;
edit.main_counter = f;
let scale = grid_scale(f);
if edit.grid_follow_main && edit.grid.scale() != scale {
edit.grid = GridValue::for_scale(scale);
}
if edit.grid_follow_main {
edit.grid_clips_markers = false;
}
if edit.nudge_follow_main && edit.nudge.scale() != scale {
edit.nudge = GridValue::for_scale(scale);
}
Ok(json!({"format": f}))
}),
cmd!(noundo "view.sub_counter", "Sub Counter", [], None, "{format?: … | null}", always, |e, p| {
@@ -73,37 +84,80 @@ pub fn specs() -> Vec<CommandSpec> {
e.session_mut().edit.sub_counter = f;
Ok(json!({"format": f}))
}),
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, or step: ±n values larger or smaller; lines?: bool}", always, |e, p| {
let g = value_or_step(p, e.session().edit.grid).ok_or_else(|| bad("view.grid", "unknown grid value"))?;
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples|clips_markers, follow_main?: bool, lines?: bool}", always, |e, p| {
let current = e.session().edit.grid;
let requested_scale = str_param(p, "format").and_then(GridScale::from_id);
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.grid", "unknown grid value or format"))?;
let lines = p.get("lines").and_then(Value::as_bool);
let follow = p.get("follow_main").and_then(Value::as_bool);
if follow == Some(true) {
let scale = grid_scale(e.session().edit.main_counter);
if g.scale() != scale {
g = GridValue::for_scale(scale);
}
}
let s = e.session_mut();
s.edit.grid = g;
if p.get("value").is_some() || p.get("step").is_some() {
s.edit.grid_clips_markers = false;
}
if let Some(scale) = requested_scale {
s.edit.grid_clips_markers = scale == GridScale::ClipsMarkers;
}
if follow == Some(true) {
s.edit.grid_clips_markers = false;
}
s.edit.grid_follow_main = follow.unwrap_or(
s.edit.grid_follow_main && !s.edit.grid_clips_markers && g.scale() == grid_scale(s.edit.main_counter),
);
if let Some(l) = lines { s.edit.grid_lines = l }
Ok(json!({"grid": g.label()}))
let format = if s.edit.grid_clips_markers { GridScale::ClipsMarkers } else { g.scale() };
Ok(json!({"grid": g.label(), "format": format.id(), "follow_main": s.edit.grid_follow_main}))
}),
cmd!(noundo "view.grid_lines", "Show Grid Lines", [], None, "{value?: bool}", always, |e, p| {
let s = e.session_mut();
s.edit.grid_lines = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.grid_lines);
Ok(json!({"value": s.edit.grid_lines}))
}),
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value or step: like grid}", always, |e, p| {
let g = value_or_step(p, e.session().edit.nudge).ok_or_else(|| bad("view.nudge", "unknown nudge value"))?;
e.session_mut().edit.nudge = g;
Ok(json!({"nudge": g.label()}))
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples, follow_main?: bool}", always, |e, p| {
let current = e.session().edit.nudge;
if str_param(p, "format").and_then(GridScale::from_id) == Some(GridScale::ClipsMarkers) {
return Err(bad("view.nudge", "Clips/Markers is available only for Grid"));
}
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.nudge", "unknown nudge value or format"))?;
let follow = p.get("follow_main").and_then(Value::as_bool);
if follow == Some(true) {
let scale = grid_scale(e.session().edit.main_counter);
if g.scale() != scale {
g = GridValue::for_scale(scale);
}
}
let edit = &mut e.session_mut().edit;
edit.nudge = g;
edit.nudge_follow_main = follow.unwrap_or(edit.nudge_follow_main && g.scale() == grid_scale(edit.main_counter));
Ok(json!({"nudge": g.label(), "format": g.scale().id(), "follow_main": edit.nudge_follow_main}))
}),
]
}
const MIN_SPP: f64 = 0.25;
const MAX_SPP: f64 = 1_000_000.0;
/// Pixel column an off-screen edit cursor is brought to when zooming (the engine does not know the viewport width).
const OFFSCREEN_CURSOR_PX: f64 = 500.0;
fn zoom(e: &mut Engine, f: f64, x: Option<f64>) -> Result<Value> {
let insertion = e.session().edit.selection.start as f64;
let z = e.zoom_mut();
let (old, scroll) = (z.samples_per_px, z.scroll as f64);
let new = (old * f).clamp(MIN_SPP, MAX_SPP);
if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) {
// Without a pointer, the edit cursor is the reference: held in place when visible, centred when off-screen.
let cursor_x = (insertion - scroll) / old;
if let Some(x) = x {
z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION);
} else if (0.0..4000.0).contains(&cursor_x) {
z.scroll = soundcraft_time::to_samples(scroll + cursor_x * (old - new)).clamp(0, MAX_POSITION);
} else {
z.scroll = soundcraft_time::to_samples(insertion - OFFSCREEN_CURSOR_PX * new).clamp(0, MAX_POSITION);
}
z.samples_per_px = new;
Ok(json!({"samples_per_px": new, "scroll": z.scroll}))
@@ -129,7 +183,33 @@ fn value_or_step(p: &Value, current: GridValue) -> Option<GridValue> {
Some((0..step.unsigned_abs().min(16)).fold(current, |g, _| g.stepped(step > 0)))
}
/// Parse grid/nudge values.
/// Maps a counter format to the corresponding Grid/Nudge scale.
fn grid_scale(format: TimeFormat) -> GridScale {
match format {
TimeFormat::BarsBeats => GridScale::BarsBeats,
TimeFormat::MinSecs => GridScale::MinSecs,
TimeFormat::Timecode => GridScale::Timecode,
TimeFormat::FeetFrames => GridScale::FeetFrames,
TimeFormat::Samples => GridScale::Samples,
}
}
/// Resolves an explicit value, a step, or a scale change while preserving the current increment.
fn requested_grid_value(p: &Value, current: GridValue) -> Option<GridValue> {
if p.get("value").is_some() || p.get("step").is_some() {
return value_or_step(p, current);
}
let scale = str_param(p, "format").and_then(GridScale::from_id);
match scale {
Some(GridScale::ClipsMarkers) => Some(current),
Some(scale) if current.scale() == scale => Some(current),
Some(scale) => Some(GridValue::for_scale(scale)),
None if p.get("follow_main").is_some() || p.get("lines").is_some() => Some(current),
None => None,
}
}
/// Parse grid/nudge values; legacy `1` and `1/1` mean a bar, while `whole` means four quarter notes.
pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
let v = p.get(key)?;
if let Some(sec) = v.get("seconds").and_then(Value::as_f64) {
@@ -138,15 +218,23 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
if let Some(f) = v.get("frames").and_then(Value::as_i64) {
return Some(GridValue::Frames(f.clamp(1, 100_000)));
}
if let Some(value) = v.get("timecode").and_then(Value::as_str).and_then(TimecodeValue::from_id) {
return Some(GridValue::Timecode(value));
}
if let Some(value) = v.get("feet_frames").and_then(Value::as_str).and_then(FeetFramesValue::from_id) {
return Some(GridValue::FeetFrames(value));
}
if let Some(n) = v.get("samples").and_then(Value::as_i64) {
return Some(GridValue::Samples(n.clamp(1, i64::from(u32::MAX))));
}
let s = v.as_str()?.trim().to_ascii_lowercase();
let triplet = s.ends_with('t') || s.contains("triplet");
let dotted = s.ends_with('.') || s.contains("dotted");
let core: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '/' || *c == ' ' || c.is_ascii_alphabetic() && *c != 't').collect();
let core = s.replace("triplet", "").replace("dotted", "");
let core = core.trim().trim_end_matches(['t', '.']);
let value = match core.trim() {
"1 bar" | "bar" | "1/1" | "1" => NoteValue::Bar,
"1" | "1/1" | "1 bar" | "bar" => NoteValue::Bar,
"whole" | "whole note" => NoteValue::Whole,
"1/2" => NoteValue::Half,
"1/4" => NoteValue::Quarter,
"1/8" => NoteValue::Eighth,
@@ -162,15 +250,59 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
mod tests {
use super::*;
use soundcraft_time::Samples;
/// Grid values accept compact and word-form musical modifiers plus every absolute scale.
#[test]
fn grid_values_parse() {
assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }));
assert_eq!(grid_param(&json!({"v": "1/8t"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
assert_eq!(grid_param(&json!({"v": "1/8 triplet"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
assert_eq!(grid_param(&json!({"v": "1/4."}), "v"), Some(GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }));
for alias in ["1", "1/1"] {
assert_eq!(grid_param(&json!({"v": alias}), "v"), Some(GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }));
}
assert_eq!(grid_param(&json!({"v": "whole"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
assert_eq!(grid_param(&json!({"v": "whole note"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
assert_eq!(grid_param(&json!({"v": {"timecode": "quarter_frame"}}), "v"), Some(GridValue::Timecode(TimecodeValue::QuarterFrame)));
assert_eq!(grid_param(&json!({"v": {"feet_frames": "foot"}}), "v"), Some(GridValue::FeetFrames(FeetFramesValue::Foot)));
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
}
/// Explicit values and formats retain Follow Main only when the selected scale still matches.
#[test]
fn explicit_grid_and_nudge_scales_keep_follow_state_consistent() {
for command in ["view.grid", "view.nudge"] {
let mut e = Engine::default();
e.execute("view.main_counter", &json!({"format": "min_secs"})).unwrap();
e.execute(command, &json!({"follow_main": true})).unwrap();
let result = e.execute(command, &json!({"value": {"seconds": 0.1}})).unwrap();
assert_eq!(result["follow_main"], true);
let result = e.execute(command, &json!({"value": {"feet_frames": "foot"}})).unwrap();
assert_eq!(result["follow_main"], false);
e.execute(command, &json!({"follow_main": true})).unwrap();
let result = e.execute(command, &json!({"format": "bars_beats"})).unwrap();
assert_eq!(result["follow_main"], false);
}
let mut e = Engine::default();
e.execute("view.grid", &json!({"format": "clips_markers"})).unwrap();
assert!(!e.session().edit.grid_follow_main);
e.execute("view.grid", &json!({"value": {"samples": 100}})).unwrap();
assert!(!e.session().edit.grid_clips_markers);
assert_eq!(e.session().edit.grid, GridValue::Samples(100));
}
/// Historical bar aliases retain meter-dependent duration; the explicit whole note remains distinct.
#[test]
fn legacy_bar_aliases_and_whole_notes_remain_distinct_in_odd_meter() {
let mut e = Engine::default();
e.session_mut().tempo = soundcraft_time::TempoMap::new(120.0, 3, 4).unwrap();
for (value, expected) in [("1", 72_000), ("1/1", 72_000), ("whole", 96_000)] {
e.execute("view.grid", &json!({"value": value, "follow_main": false})).unwrap();
let s = e.session();
assert_eq!(s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate), expected);
}
}
fn view(e: &Engine) -> (f64, Samples) {
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
}
@@ -186,6 +318,17 @@ mod tests {
assert_eq!(view(&e), (100.0, 48_000));
}
#[test]
fn zoom_in_brings_an_offscreen_edit_cursor_into_view() {
let mut e = Engine::default();
e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap();
e.execute("edit.select", &json!({"start": 1_000_000, "end": 1_000_000})).unwrap();
e.execute("view.zoom_in", &json!({})).unwrap();
let (spp, scroll) = view(&e);
let x = (1_000_000.0 - scroll as f64) / spp;
assert!((x - OFFSCREEN_CURSOR_PX).abs() < 1.0, "cursor at {x}px");
}
#[test]
fn zoom_and_scroll_stay_in_range() {
let mut e = Engine::default();
@@ -222,6 +365,7 @@ mod tests {
assert_eq!(state(&e), before);
}
/// Grid and Nudge steps drive edit movement while preserving their selected increments.
#[test]
fn grid_and_nudge_values_step_and_nudges_can_use_the_next_value() {
let mut e = Engine::default();
@@ -243,4 +387,32 @@ mod tests {
assert_eq!(e.session().edit.selection.start, 6_000);
assert_eq!(e.session().edit.nudge.label(), "1/16 note");
}
/// Grid and Nudge expose independent scale-following state, while Clips/Markers remains Grid-only.
#[test]
fn grid_and_nudge_choose_scales_follow_the_main_counter_and_keep_clips_markers_grid_only() {
let mut e = Engine::default();
e.execute("view.grid", &json!({"format": "timecode", "follow_main": false})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::Frame));
e.execute("view.grid", &json!({"value": {"timecode": "quarter_frame"}})).unwrap();
e.execute("view.grid", &json!({"format": "clips_markers", "follow_main": false})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::QuarterFrame));
assert!(e.session().edit.grid_clips_markers);
assert!(e.execute("view.nudge", &json!({"format": "clips_markers"})).is_err());
e.execute("view.grid", &json!({"follow_main": true})).unwrap();
e.execute("view.nudge", &json!({"follow_main": true})).unwrap();
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Seconds(1.0), GridValue::Seconds(1.0)));
assert!(!e.session().edit.grid_clips_markers);
e.execute("view.main_counter", &json!({"format": "samples"})).unwrap();
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Samples(100), GridValue::Samples(100)));
assert!(e.session().edit.grid_follow_main && e.session().edit.nudge_follow_main);
e.execute("view.grid", &json!({"format": "bars_beats", "follow_main": false})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::default());
assert!(!e.session().edit.grid_follow_main);
e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::default());
assert_eq!(e.session().edit.nudge, GridValue::Timecode(TimecodeValue::Frame));
}
}
+127 -6
View File
@@ -1,12 +1,15 @@
//! Borrowed document equality for the saved-state indicator.
use soundcraft_model::{EditState, Session};
use soundcraft_model::{EditState, Mixer, Session, Track};
/// Equal apart from the selection, playhead and zoom, without copying document data.
/// Equal apart from navigation and live input flags, without copying document data.
pub(super) fn same_document(saved: &Session, doc: &Session) -> bool {
document_values(saved, &saved.edit) == document_values(doc, &saved.edit)
&& saved.tracks.len() == doc.tracks.len()
&& saved.tracks.iter().zip(&doc.tracks).all(|(saved, doc)| track_values(saved) == track_values(doc))
}
/// Borrow persistent session fields; track fields are compared separately.
fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl PartialEq + 'a {
// No `..`: adding a document field must also decide how it affects saved-state equality.
let Session {
@@ -18,7 +21,7 @@ fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl Partia
timecode_start,
tempo,
key_signatures,
tracks,
tracks: _,
sources,
videos,
busses,
@@ -35,11 +38,66 @@ fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl Partia
} = s;
// Tuples implement PartialEq up to twelve elements. These contain only borrowed fields.
(
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, tracks, sources, videos, busses),
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, sources, videos, busses),
(outputs, markers, groups, auto_names, edit_values(edit, navigation), next_id, comments, midi_originals, pool, folder),
)
}
/// Borrow persistent track fields without allocating or copying media.
fn track_values(track: &Track) -> impl PartialEq + '_ {
let Track {
id,
name,
kind,
format,
color,
height,
playlists,
active_playlist,
mixer,
automation,
view,
hidden,
inactive,
frozen,
comments,
folder,
folder_open,
instrument,
ticks_timebase,
elastic,
pinned,
} = track;
(
(id, name, kind, format, color, height, playlists, active_playlist, mixer_values(mixer), automation, view),
(hidden, inactive, frozen, comments, folder, folder_open, instrument, ticks_timebase, elastic, pinned),
)
}
/// Borrow persistent mixer fields without allocating or copying media.
fn mixer_values(mixer: &Mixer) -> impl PartialEq + '_ {
let Mixer {
volume_db,
pan,
mute,
solo,
solo_safe,
record_arm: _,
input_monitor: _,
phase_invert,
input,
output,
inserts,
sends,
automation_mode,
trim_db,
vca,
surround,
} = mixer;
((volume_db, pan, mute, solo, solo_safe, phase_invert, input, output, inserts, sends, automation_mode), (trim_db, vca, surround))
}
/// Borrow edit settings while normalizing transient navigation to the saved values.
fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl PartialEq + 'a {
let EditState {
tool,
@@ -52,6 +110,9 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
grid,
nudge,
grid_lines,
grid_follow_main,
nudge_follow_main,
grid_clips_markers,
main_counter,
sub_counter,
zoom: _,
@@ -130,7 +191,17 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
scrolling,
solo_mode,
),
(pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values),
(
grid_follow_main,
nudge_follow_main,
grid_clips_markers,
pre_fader_metering,
delay_compensation,
keyboard_focus,
&navigation.timeline_selection,
flags,
values,
),
)
}
@@ -141,7 +212,7 @@ mod tests {
use soundcraft_time::{Range, TimeFormat};
use std::sync::Arc;
// The previous comparison is an oracle for preserving the saved-state policy.
/// The clone-based policy, extended to ignore live input flags, serves as a test oracle.
fn previous_same_document(saved: &Session, doc: &Session) -> bool {
let mut doc = doc.clone();
let (from, to) = (&saved.edit, &mut doc.edit);
@@ -151,9 +222,14 @@ mod tests {
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
for (saved, doc) in saved.tracks.iter().zip(&mut doc.tracks) {
doc.mixer.record_arm = saved.mixer.record_arm;
doc.mixer.input_monitor = saved.mixer.input_monitor;
}
*saved == doc
}
/// Check both directions and compare against the normalized clone oracle.
fn check(saved: &Session, doc: &Session, expected: bool, field: &str) {
assert_eq!(same_document(saved, doc), expected, "{field}");
assert_eq!(same_document(doc, saved), expected, "{field}, reversed");
@@ -186,6 +262,9 @@ mod tests {
("grid", |e| e.grid = soundcraft_time::GridValue::Seconds(2.0)),
("nudge", |e| e.nudge = soundcraft_time::GridValue::Seconds(2.0)),
("grid_lines", |e| e.grid_lines = !e.grid_lines),
("grid_follow_main", |e| e.grid_follow_main = !e.grid_follow_main),
("nudge_follow_main", |e| e.nudge_follow_main = !e.nudge_follow_main),
("grid_clips_markers", |e| e.grid_clips_markers = !e.grid_clips_markers),
("main_counter", |e| e.main_counter = TimeFormat::Samples),
("sub_counter", |e| e.sub_counter = None),
("zoom_toggle", |e| e.zoom_toggle = Some(ZoomToggle { samples_per_px: 64.0, scroll: 0, heights: Vec::new() })),
@@ -252,6 +331,48 @@ mod tests {
assert_eq!(same_document(&doc, &doc), previous_same_document(&doc, &doc), "NaN keeps the previous PartialEq behavior");
}
/// Only arm and monitoring are transient; every other mixer field still contributes to dirtiness.
#[test]
fn live_flags_do_not_hide_persistent_mixer_changes() {
use soundcraft_model::{AutomationMode, BusId, Insert, Route, SendSlot, SurroundPan};
let mut saved = Session::default();
let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut live = saved.clone();
live.track_mut(track).unwrap().mixer.record_arm = true;
live.track_mut(track).unwrap().mixer.input_monitor = true;
check(&saved, &live, true, "live input flags");
let edits: &[(&str, fn(&mut Mixer))] = &[
("volume", |m| m.volume_db = -6.0),
("pan", |m| m.pan[0] = 0.5),
("mute", |m| m.mute = true),
("solo", |m| m.solo = true),
("solo_safe", |m| m.solo_safe = true),
("phase_invert", |m| m.phase_invert = true),
("input", |m| m.input = Route::Hardware("Mic".into())),
("output", |m| m.output = Route::Bus(BusId(900))),
("inserts", |m| m.inserts[0] = Some(Insert::new("eq7"))),
("sends", |m| m.sends[0] = Some(SendSlot::new(Route::Main))),
("automation_mode", |m| m.automation_mode = AutomationMode::Touch),
("trim_db", |m| m.trim_db = -3.0),
("vca", |m| m.vca = Some(900)),
("surround", |m| m.surround = Some(SurroundPan::default())),
];
for &(field, edit) in edits {
let mut doc = live.clone();
edit(&mut doc.track_mut(track).unwrap().mixer);
check(&saved, &doc, false, field);
}
let mut doc = live.clone();
doc.tracks.push(doc.tracks[0].clone());
check(&saved, &doc, false, "track count");
let mut doc = live.clone();
doc.tracks[0].id = TrackId(900);
check(&saved, &doc, false, "track identity");
let mut doc = live.clone();
doc.tracks[0].mixer.volume_db = f32::NAN;
assert!(!same_document(&doc, &doc), "persistent NaN keeps PartialEq semantics");
}
#[test]
fn non_finite_saved_zoom_keeps_the_previous_partial_eq_behavior() {
let mut saved = Session::default();
+11 -21
View File
@@ -528,9 +528,15 @@ fn join_gain_env(l: &mut Clip, r: &Clip) {
}
/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them
/// elsewhere (see [`destinations`]).
/// elsewhere (see [`destinations`]). Same-track moves clamped to zero leave all material intact.
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize {
let ids: HashSet<ClipId> = ids.iter().copied().collect();
// Limit the shift before removing any clips, so a stationary edit cannot overwrite overlaps.
let limit = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| ids.contains(&c.id) && !locked(c)).map(|c| c.start.saturating_neg()).max();
let delta = delta.max(limit.unwrap_or(0));
if delta == 0 && dest.iter().all(|(from, to)| from == to) {
return 0;
}
let mut moving: Vec<(TrackId, Clip)> = Vec::new();
for t in &mut s.tracks {
let tid = t.id;
@@ -546,8 +552,6 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
}
}
let n = moving.len();
// Limit the shift once, so the group stops at zero together and keeps its spacing.
let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0));
let placed = moving.into_iter().map(|(tid, mut c)| {
c.start = c.start.saturating_add(delta);
(lands(dest, tid), c)
@@ -572,20 +576,7 @@ fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) {
/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder at any level.
pub fn shown(s: &Session, t: &Track) -> bool {
!t.hidden && !folder_collapsed(s, t)
}
/// Hidden because a folder at any level above this track is closed.
fn folder_collapsed(s: &Session, track: &Track) -> bool {
let mut current = track.folder;
for _ in 0..64 {
let Some(folder) = current.and_then(|folder| s.track(folder)) else { return false };
if !folder.folder_open {
return true;
}
current = folder.folder;
}
false
s.track_shown(t)
}
/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance
@@ -790,12 +781,11 @@ pub fn shuffle_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(T
/// How far a Grabber drag by `raw` moves `clip` in Grid mode: Absolute puts its sync point (its
/// start when it has none inside the clip) on the nearest grid line; Relative moves it by whole
/// grid steps, keeping its offset from the grid.
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool) -> Samples {
let (g, sr, map, rate) = (&s.edit.grid, s.sample_rate, &s.tempo, s.frame_rate);
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool, moving: &[ClipId]) -> Samples {
let sync = if (0..clip.length).contains(&clip.sync_point) { clip.sync_point } else { 0 };
let anchor = clip.start.saturating_add(sync);
let from = if relative { g.line_at_or_before(anchor, sr, map, rate) } else { anchor };
g.snap(from.saturating_add(raw), sr, map, rate).saturating_sub(from)
let from = if relative { s.grid_line_at_or_before_excluding(anchor, moving) } else { anchor };
s.snap_to_grid_excluding(from.saturating_add(raw), moving).saturating_sub(from)
}
/// Trim a clip's edges to `start` and/or `end` like the Trim tool, never past its file. In
+3 -3
View File
@@ -330,9 +330,9 @@ fn grid_moves_snap_the_clip_start_or_keep_its_offset() {
let (s, t) = session_with_clip();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
let c = s.track(t).unwrap().clips()[0].clone();
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000);
assert_eq!(grid_delta(&s, &c, step / 3, true), 0);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false, &[c.id]), step);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true, &[c.id]), step + 1000);
assert_eq!(grid_delta(&s, &c, step / 3, true, &[c.id]), 0);
}
#[test]
+172 -18
View File
@@ -42,19 +42,122 @@ pub use strip::{StripMode, StripNames, StripScan, bounded_to_track, name_strippe
/// one. Only a regular file qualifies: a device or a pipe named by a session or a command would
/// never end or never answer. Errors name the file.
pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>> {
let path = path.as_ref();
read_file_within(path.as_ref(), false)
}
/// [`read_file`]; a `session` file is refused ([`session_size`]) past [`session_limit`].
fn read_file_within(path: &Path, session: bool) -> Result<Vec<u8>> {
use std::io::Read as _;
crate::file_access::check_read(path).map_err(EngineError::Io)?;
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
if !std::fs::metadata(path).map_err(err)?.is_file() {
let meta = std::fs::metadata(path).map_err(err)?;
if !meta.is_file() {
return Err(EngineError::Io(format!("{}: not a regular file", path.display())));
}
std::fs::read(path).map_err(err)
if !session {
return std::fs::read(path).map_err(err);
}
session_size(path.display(), meta.len(), false)?;
// The file may have grown since: never read more than one byte past the limit.
let mut data = Vec::new();
std::fs::File::open(path).map_err(err)?.take(session_limit().saturating_add(1)).read_to_end(&mut data).map_err(err)?;
session_size(path.display(), data.len() as u64, false)?;
Ok(data)
}
/// [`read_file`] as UTF-8 text.
pub fn read_text(path: impl AsRef<Path>) -> Result<String> {
let path = path.as_ref();
String::from_utf8(read_file(path)?).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
utf8(path, read_file(path)?)
}
fn utf8(path: &Path, bytes: Vec<u8>) -> Result<String> {
String::from_utf8(bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
}
/// The largest session (`.scraft`) SoundCraft opens or saves. A session is read whole and parsed
/// in one go, so a larger one is refused by its size before it is read, and never written (it
/// could not be opened again); real sessions are far smaller.
pub const MAX_SESSION_BYTES: u64 = 256 << 20;
#[cfg(test)]
thread_local! {
/// A smaller limit than [`MAX_SESSION_BYTES`] for the tests on this thread ([`SessionLimit`]).
static TEST_SESSION_LIMIT: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
}
/// [`MAX_SESSION_BYTES`] (in tests, the limit a [`SessionLimit`] set on this thread).
pub(crate) fn session_limit() -> u64 {
#[cfg(test)]
if let Some(limit) = TEST_SESSION_LIMIT.with(std::cell::Cell::get) {
return limit;
}
MAX_SESSION_BYTES
}
/// While it lives, sessions on this thread are opened and saved up to the limit given to
/// [`SessionLimit::set`] instead of [`MAX_SESSION_BYTES`], so tests reach it with small sessions.
#[cfg(test)]
pub(crate) struct SessionLimit;
#[cfg(test)]
impl SessionLimit {
pub(crate) fn set(limit: u64) -> SessionLimit {
TEST_SESSION_LIMIT.with(|l| l.set(Some(limit)));
SessionLimit
}
}
#[cfg(test)]
impl Drop for SessionLimit {
fn drop(&mut self) {
TEST_SESSION_LIMIT.with(|l| l.set(None));
}
}
/// Refuses the session `name` of `len` bytes when it is larger than [`session_limit`]: too large
/// to open, or, when `saving`, to save.
pub(crate) fn session_size(name: impl std::fmt::Display, len: u64, saving: bool) -> Result<()> {
let limit = session_limit();
if len <= limit {
return Ok(());
}
let most = if limit >= 1 << 20 { format!("{} MiB", limit >> 20) } else { format!("{limit} bytes") };
Err(EngineError::Io(if saving {
format!("{name} is too large to save: over {most}, more than SoundCraft opens")
} else {
format!("{name} is too large to open (over {most})")
}))
}
/// [`read_text`] for a session file: refused, unread, past [`session_limit`].
pub(crate) fn read_session_text(path: impl AsRef<Path>) -> Result<String> {
let path = path.as_ref();
utf8(path, read_file_within(path, true)?)
}
/// A bare `.scraft` handed over as bytes (the browser) as text: refused, uncopied, past
/// [`session_limit`].
pub(crate) fn session_bytes_text(name: &str, bytes: &[u8]) -> Result<String> {
session_size(name, bytes.len() as u64, false)?;
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))
}
/// How many bytes [`Session::to_json`] makes of `s`, counted without keeping them.
fn json_len(s: &Session) -> u64 {
struct Count(u64);
impl std::io::Write for Count {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 = self.0.saturating_add(buf.len() as u64);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut n = Count(0);
// A session that cannot be written counts as nothing here: `to_json` reports that error.
serde_json::to_writer_pretty(&mut n, s).map_or(0, |()| n.0)
}
/// Is there a file at `path` the automation read root lets us see? A path outside the root is
@@ -178,6 +281,13 @@ pub struct Bounce {
enc: Option<soundcraft_audio_io::FileEncoder<Out>>,
}
/// The Broadcast Wave stamp of timeline position `at`: the session's timecode start plus `at`, in
/// samples since midnight (`bext` TimeReference; 0 before midnight).
fn time_stamp(s: &Session, at: Samples) -> soundcraft_audio_io::BwfInfo {
let time_reference = u64::try_from(s.timecode_start.saturating_add(at)).unwrap_or(0);
soundcraft_audio_io::BwfInfo { time_reference, ..Default::default() }
}
/// A finished bounce: the size of its file and its levels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bounced {
@@ -208,6 +318,9 @@ impl Bounce {
let part = path.with_file_name(format!("{name}.part"));
crate::file_access::check_write(path).map_err(EngineError::Io)?;
crate::file_access::check_write(&part).map_err(EngineError::Io)?;
// A WAV bounce says where it starts on the session's timecode, as other DAWs' bounces do,
// so post and broadcast tools can place it (only WAV has a `bext` chunk to say it in).
let opts = &EncodeOptions { bwf: opts.bwf.clone().or_else(|| Some(time_stamp(&s, r.start))), ..opts.clone() };
let enc = (|| -> std::result::Result<_, soundcraft_audio_io::AudioError> {
let enc = soundcraft_audio_io::FileEncoder::new(Out::create(&part)?, opts, channels, s.sample_rate.hz(), Some(fmt.channel_mask()))?;
enc.check_room(u64::try_from(r.len()).unwrap_or(0))?;
@@ -487,7 +600,6 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
let mut s = session.clone();
let from = s.folder.clone();
let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir));
let mut written = 0;
// A file can stay put for all the sources that name it, unless their audio differs.
let shared_paths: Vec<bool> = {
let mut audio_by_path: HashMap<&str, HashSet<Option<*const SourceAudio>>> = HashMap::new();
@@ -496,8 +608,11 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
}
s.sources.iter().map(|src| audio_by_path.get(src.path.as_str()).is_some_and(|a| a.len() > 1)).collect()
};
// Files written for audio during this save: sources playing the same audio share one.
// Files for audio in this save: sources playing the same audio share one. The files still to
// write (their stem, audio and the sources naming them) are written once the size is checked.
let mut files: HashMap<*const SourceAudio, String> = HashMap::new();
let mut queued: HashMap<*const SourceAudio, usize> = HashMap::new();
let mut pending: Vec<(String, Arc<SourceAudio>, Vec<usize>)> = Vec::new();
for (i, src) in s.sources.iter_mut().enumerate() {
if !same_dir
&& s.pool.get(src.id).is_none()
@@ -523,7 +638,14 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
src.format = FileFormat::Wav;
continue;
}
make_dir(&audio_dir)?;
if let Some((stem, _, sources)) = queued.get(&Arc::as_ptr(audio)).and_then(|&k| pending.get_mut(k)) {
// As long as any name the file can get, so the size checked is an upper bound.
src.path = format!("Audio Files/{stem}_9999.wav");
src.unsaved = false;
src.format = FileFormat::Wav;
sources.push(i);
continue;
}
let mut stem = sanitize_name(&src.name);
if overwrite {
// One file per source id so autosaves never collide or pile up.
@@ -535,23 +657,46 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
continue;
}
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
let (name, new) = store_wav(&audio_dir, &stem, &bytes)?;
src.path = format!("Audio Files/{name}");
src.path = format!("Audio Files/{stem}_9999.wav");
src.unsaved = false;
src.format = FileFormat::Wav;
files.insert(Arc::as_ptr(audio), src.path.clone());
written += usize::from(new);
}
if written > 0 {
sync_dir(&audio_dir);
queued.insert(Arc::as_ptr(audio), pending.len());
pending.push((stem, Arc::clone(audio), vec![i]));
}
s.folder = Some(dir.clone());
if !overwrite && let Some(stem) = p.file_stem().and_then(OsStr::to_str) {
s.name = stem.to_string();
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
// Before any audio is written: a session too large to open again is not saved (#892). With no
// audio to write, the text checked is the text saved.
let checked = if pending.is_empty() {
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
session_size(p.display(), text.len() as u64, true)?;
Some(text)
} else {
session_size(p.display(), json_len(&s), true)?;
None
};
let mut written = 0;
for (stem, audio, sources) in &pending {
make_dir(&audio_dir)?;
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
let (name, new) = store_wav(&audio_dir, stem, &bytes)?;
for &i in sources {
if let Some(src) = s.sources.get_mut(i) {
src.path = format!("Audio Files/{name}");
}
}
written += usize::from(new);
}
if written > 0 {
sync_dir(&audio_dir);
}
let text = match checked {
Some(text) => text,
None => s.to_json().map_err(|err| EngineError::Io(err.to_string()))?,
};
make_dir(&dir)?;
// Atomic-ish write: temp file then rename.
let tmp = p.with_extension("scraft.tmp");
@@ -734,6 +879,12 @@ fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::colle
if z.len() > limits.entries {
return Err(EngineError::Io("bundle: too many files".into()));
}
// Entries are stored, so the files we keep can only outgrow the bundle when several entries
// point at the same bytes: a small bundle naming one payload thousands of times would be
// copied out once per name.
if z.has_overlapping_files().map_err(zerr)? {
return Err(EngineError::Io("bundle: files share data".into()));
}
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
for i in 0..z.len() {
let mut f = z.by_index(i).map_err(zerr)?;
@@ -746,10 +897,13 @@ fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::colle
if total > limits.bytes {
return Err(EngineError::Io("bundle: too large".into()));
}
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
if is_session && text.is_none() {
session_size(format_args!("bundle: {rel}"), size, false)?;
}
let mut data = Vec::new();
// The declared size is hostile too: never read past it.
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
if is_session && text.is_none() {
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
} else {
+52 -4
View File
@@ -69,6 +69,8 @@ fn session_bundle_within(session: &Session, limits: BundleLimits) -> Result<(Vec
src.format = FileFormat::Wav;
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
// Its session too: one the reader would refuse is not saved (#892).
super::session_size("the session", text.len() as u64, true)?;
fits(text.len())?;
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
w.write_all(text.as_bytes()).map_err(ioerr)?;
@@ -100,10 +102,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
read_bundle(bytes, BUNDLE_LIMITS)?
} else {
(
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
std::collections::HashMap::new(),
)
(super::session_bytes_text(name, bytes)?, std::collections::HashMap::new())
};
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let mut missing = Vec::new();
@@ -149,6 +148,38 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
mod bundle_tests {
use super::*;
#[test]
fn a_session_too_large_to_parse_is_refused_from_bytes() {
// #892: a session the browser hands over as bytes has the cap a session file has. The
// bytes are zeroed on allocation and never read, so they take no memory.
let huge = vec![0u8; usize::try_from(super::super::MAX_SESSION_BYTES).unwrap() + 1];
let err = open_session_bytes(&mut Engine::default(), "Huge.scraft", &huge).unwrap_err().to_string();
assert_eq!(err, "Huge.scraft is too large to open (over 256 MiB)");
}
#[test]
fn bundles_and_bare_sessions_keep_to_the_session_limit_both_ways() {
// #892, with the limit lowered for this test: a bundle is saved only if its session can
// be opened again (#433), and its reader and the bare-bytes reader refuse one past it.
let e = crate::demo::demo_engine();
let (bundle, fresh) = session_bundle(e.session()).unwrap();
let inside = fresh.to_json().unwrap().len() as u64;
let bare = e.session().to_json().unwrap();
{
let _limit = super::super::SessionLimit::set(inside.max(bare.len() as u64));
assert!(session_bundle(e.session()).is_ok());
assert!(open_session_bytes(&mut Engine::default(), "Song.scraftz", &bundle).is_ok());
assert!(open_session_bytes(&mut Engine::default(), "Song.scraft", bare.as_bytes()).is_ok());
}
let _limit = super::super::SessionLimit::set(inside.min(bare.len() as u64) - 1);
let err = session_bundle(e.session()).unwrap_err().to_string();
assert!(err.starts_with("the session is too large to save"), "{err}");
let err = open_session_bytes(&mut Engine::default(), "Song.scraftz", &bundle).unwrap_err().to_string();
assert!(err.starts_with("bundle: ") && err.contains(".scraft is too large to open"), "{err}");
let err = open_session_bytes(&mut Engine::default(), "Song.scraft", bare.as_bytes()).unwrap_err().to_string();
assert!(err.starts_with("Song.scraft is too large to open"), "{err}");
}
#[test]
fn bundle_round_trips_session_and_audio() {
let mut e = crate::demo::demo_engine();
@@ -194,6 +225,23 @@ mod bundle_tests {
}
}
#[test]
fn entries_sharing_one_payload_are_refused() {
use std::io::Write as _;
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
w.start_file("Song.scraft", opts).unwrap();
w.write_all(Session::default().to_json().unwrap().as_bytes()).unwrap();
w.start_file("Audio Files/a.wav", opts).unwrap();
w.write_all(&[7u8; 4096]).unwrap();
for i in 0..100 {
w.shallow_copy_file("Audio Files/a.wav", &format!("Audio Files/copy{i}.wav")).unwrap();
}
let bundle = w.finish().unwrap().into_inner();
let err = read_bundle(&bundle, BUNDLE_LIMITS).unwrap_err();
assert!(err.to_string().contains("share data"), "{err}");
}
#[test]
fn a_bundle_is_saved_only_if_it_opens_again() {
// #433: the writer keeps to the reader's limits.
+25 -3
View File
@@ -82,15 +82,37 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
for c in t.clips() {
if let ClipContent::Midi { sequence } = &c.content {
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
for n in &sequence.notes {
// Only what plays: notes starting inside the clip, cut at its end.
let clip_end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
for n in sequence.notes.iter().filter(|n| n.start.saturating_add(base) < clip_end) {
let mut n = *n;
n.start = n.start.saturating_add(base);
n.length = n.length.min(clip_end - n.start).max(1);
seq.notes.push(n);
}
for ev in &sequence.ctrls {
let mut kept: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
for ev in sequence.ctrls.iter().filter(|ev| ev.tick.saturating_add(base) < clip_end) {
let mut ev = *ev;
ev.tick = ev.tick.saturating_add(base);
seq.ctrls.push(ev);
kept.push(ev);
}
// A sustain still held at the clip end is released there.
let mut release: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
for ev in &kept {
if ev.kind != soundcraft_midi::CtrlKind::Cc(64) {
continue;
}
match release.iter_mut().find(|r| r.channel == ev.channel) {
Some(r) if ev.tick >= r.tick => *r = *ev,
Some(_) => {}
None => release.push(*ev),
}
}
seq.ctrls.append(&mut kept);
for mut r in release.into_iter().filter(|r| r.value >= 64) {
r.tick = clip_end;
r.value = 0;
seq.ctrls.push(r);
}
}
}
+92 -2
View File
@@ -3,7 +3,7 @@ use super::bundle::save_session_bundle;
use super::import::add_source;
use super::import::{MAX_IMPORT_SAMPLES, at_rate, relink_videos};
use super::{
Arc, Engine, EngineError, OsStr, Path, PathBuf, Result, Session, SourceAudio, file_key, read_file, read_text, stored_path, write_session,
Arc, Engine, EngineError, OsStr, Path, PathBuf, Result, Session, SourceAudio, file_key, read_file, read_session_text, stored_path, write_session,
};
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
@@ -44,7 +44,7 @@ pub fn open_session(e: &mut Engine, path: impl AsRef<Path>) -> Result<Vec<String
/// [`open_session`], following the media paths in it as if they were stored in the session file
/// `links` (crash recovery: the session written beside the autosave, its paths from the original).
pub(crate) fn open_session_linked(e: &mut Engine, path: &Path, links: &Path) -> Result<Vec<String>> {
let text = read_text(path)?;
let text = read_session_text(path)?;
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?;
let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
let mut missing = Vec::new();
@@ -98,6 +98,96 @@ mod save_tests {
use super::*;
use serde_json::json;
/// A new, empty scratch folder, removed again when the test ends, passed or not.
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(tag: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!("soundcraft-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_session_file_too_large_to_parse_is_refused_unread() {
// #892: a session file is read whole and parsed; past the cap it is refused by its size.
// The file's length is set, not written, so it takes no time or space to make.
let dir = Scratch::new("huge-session");
let path = dir.0.join("Huge.scraft");
std::fs::File::create(&path).unwrap().set_len(crate::io::MAX_SESSION_BYTES + 1).unwrap();
let mut e = crate::demo::demo_engine();
let err = open_session(&mut e, &path).unwrap_err().to_string();
assert_eq!(err, format!("{} is too large to open (over 256 MiB)", path.display()));
assert_eq!(e.session().tracks.len(), crate::demo::demo_engine().session().tracks.len());
let err = e.execute("file.import_session_data", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
assert!(err.contains("too large to open"), "{err}");
}
#[test]
fn a_session_too_large_to_open_is_not_saved_and_leaves_nothing_behind() {
// #892: what SoundCraft saves, it opens again. A refused save writes no audio and no
// session; the limit is lowered for this test so a small session reaches it.
let dir = Scratch::new("refused-save");
let path = |p: &str| dir.0.join(p).to_string_lossy().into_owned();
save_session(&mut with_take(0.5), &path("A/Song.scraft")).unwrap();
let saved = std::fs::metadata(path("A/Song.scraft")).unwrap().len();
let untouched = |sub: &str| assert!(!dir.0.join(sub).exists(), "{sub} was written");
{
let _limit = crate::io::SessionLimit::set(saved - 1);
let mut e = with_take(0.5);
let err = save_session(&mut e, &path("B/Song.scraft")).unwrap_err().to_string();
assert_eq!(err, format!("{} is too large to save: over {} bytes, more than SoundCraft opens", path("B/Song.scraft"), saved - 1));
assert!(e.is_dirty() && e.path.is_none());
let err = e.execute("session.save_copy", &json!({"path": path("C/Song.scraft")})).unwrap_err().to_string();
assert!(err.contains("too large to save"), "{err}");
for sub in ["B", "C"] {
untouched(sub);
}
// An open refuses the session saved before, now over the limit.
let err = open_session(&mut Engine::default(), path("A/Song.scraft")).unwrap_err().to_string();
assert!(err.contains("too large to open"), "{err}");
}
{
// A template holds no audio or clips: a smaller limit refuses it.
let _limit = crate::io::SessionLimit::set(100);
std::fs::create_dir_all(dir.0.join("D")).unwrap();
let err = with_take(0.5).execute("session.save_template", &json!({"path": path("D/Song.scraft")})).unwrap_err().to_string();
assert!(err.contains("too large to save"), "{err}");
untouched("D/Song.scraft");
}
// Checked before the audio file is named, against the longest name it can get: a
// session at the limit plus that margin is saved, and is the same size as before.
let _limit = crate::io::SessionLimit::set(saved + 5);
save_session(&mut with_take(0.5), &path("B/Song.scraft")).unwrap();
assert_eq!(std::fs::metadata(path("B/Song.scraft")).unwrap().len(), saved);
assert_eq!(reopen(&path("B/Song.scraft")).session().sources.len(), 1);
}
#[test]
fn a_saved_session_parses_as_it_did_through_a_tree() {
// #892: parsing the text directly gives the document the `Value` round trip gave.
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &json!({"track": "Kick", "db": -7.0})).unwrap();
let text = e.session().to_json().unwrap();
let mut old: Session = serde_json::from_value(serde_json::from_str(&text).unwrap()).unwrap();
old.sanitize();
assert_eq!(Session::from_json(&text).unwrap(), old);
let mut r = Engine::default();
let dir = Scratch::new("reparse");
let path = dir.0.join("Song.scraft").to_string_lossy().into_owned();
save_session(&mut e, &path).unwrap();
assert_eq!(open_session(&mut r, &path).unwrap(), Vec::<String>::new());
assert_eq!(r.session().tracks, e.session().tracks);
}
#[test]
fn autosave_copy_leaves_document_untouched_and_reuses_audio() {
let mut e = crate::demo::demo_engine();
+107 -1
View File
@@ -119,6 +119,8 @@ pub struct Clipboard {
pub markers: Vec<soundcraft_model::MemoryLocation>,
/// How far the copied range started after a grid line; Relative Grid pastes keep it.
pub grid_offset: Samples,
/// Original insertion point when the clipboard was populated by Cut.
pub cut_from: Option<Samples>,
/// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too.
pub automation_only: bool,
/// The Auto Rename schemes of the copied clips, keyed as in their `auto_name`.
@@ -128,7 +130,7 @@ pub struct Clipboard {
impl Clipboard {
/// An empty clipboard for material copied from `range`.
pub fn of_range(s: &Session, range: soundcraft_time::Range) -> Clipboard {
let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate);
let line = s.grid_line_at_or_before(range.start);
Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() }
}
@@ -279,6 +281,13 @@ impl Engine {
Arc::make_mut(&mut self.doc)
}
/// Mutable access for live state that redraws and reaches playback but is not part of the
/// saved document. Callers must also keep the fields out of serialization and Undo/Redo.
pub(crate) fn runtime_session_mut(&mut self) -> &mut Session {
self.revision = self.revision.wrapping_add(1);
Arc::make_mut(&mut self.doc)
}
/// Like `session_mut`, for `edit.zoom` only and without bumping `revision`.
pub fn zoom_mut(&mut self) -> &mut ZoomState {
self.dirty = true;
@@ -334,20 +343,34 @@ impl Engine {
self.undo.iter().map(|h| h.label.clone()).collect()
}
/// Bound Undo and Redo together, dropping farthest redo entries before oldest undo entries.
pub fn set_history_limit(&mut self, limit: usize) {
let limit = limit.max(1);
self.history_limit = limit;
let excess = self.undo.len().saturating_add(self.redo.len()).saturating_sub(limit);
let from_redo = excess.min(self.redo.len());
self.redo.drain(..from_redo);
self.undo.drain(..excess - from_redo);
}
/// Restore the previous document while retaining current edit mode and live input flags.
pub fn undo(&mut self) -> bool {
let Some(h) = self.undo.pop() else { return false };
let cur = std::mem::replace(&mut self.doc, h.doc);
self.keep_edit_mode(&cur);
self.keep_runtime_track_states(&cur);
self.redo.push(HistoryEntry { label: h.label, doc: cur });
self.revision = self.revision.wrapping_add(1);
self.dirty = true;
true
}
/// Restore the next document while retaining current edit mode and live input flags.
pub fn redo(&mut self) -> bool {
let Some(h) = self.redo.pop() else { return false };
let cur = std::mem::replace(&mut self.doc, h.doc);
self.keep_edit_mode(&cur);
self.keep_runtime_track_states(&cur);
self.undo.push(HistoryEntry { label: h.label, doc: cur });
self.revision = self.revision.wrapping_add(1);
self.dirty = true;
@@ -368,6 +391,25 @@ impl Engine {
}
}
/// Undo and Redo must not restore live input states captured in document snapshots. A track
/// absent from the current document comes back safe rather than inheriting a historical arm.
fn keep_runtime_track_states(&mut self, from: &Session) {
let states: std::collections::HashMap<_, _> = from
.tracks
.iter()
.filter(|track| track.mixer.record_arm || track.mixer.input_monitor)
.map(|track| (track.id, (track.mixer.record_arm, track.mixer.input_monitor)))
.collect();
let state = |id| states.get(&id).copied().unwrap_or((false, false));
// Keep shared snapshots untouched when the restored flags already match.
if self.doc.tracks.iter().all(|track| (track.mixer.record_arm, track.mixer.input_monitor) == state(track.id)) {
return;
}
for track in &mut Arc::make_mut(&mut self.doc).tracks {
(track.mixer.record_arm, track.mixer.input_monitor) = state(track.id);
}
}
/// Record an undo point manually (for interactive gestures committed by the UI).
pub fn push_undo(&mut self, label: &str, before: Arc<Session>) {
if Arc::ptr_eq(&before, &self.doc) {
@@ -684,6 +726,59 @@ mod merge_tests {
mod dirty_tests {
use super::*;
/// Lowering the cap immediately removes the oldest retained Undo steps.
#[test]
fn reducing_the_history_limit_drops_the_oldest_steps_immediately() {
let mut e = Engine::default();
for _ in 0..4 {
e.execute("track.new", &json!({})).unwrap();
}
assert_eq!(e.undo_history().len(), 4);
e.set_history_limit(2);
assert_eq!(e.undo_history().len(), 2);
assert!(e.undo());
assert!(e.undo());
assert!(!e.undo());
}
/// A shared history cap retains the closest redo steps without letting either direction exceed it.
#[test]
fn lowering_history_limit_bounds_undo_and_redo_together() {
for (requested, past, future) in [(0, 1, 0), (1, 1, 0), (2, 2, 0), (3, 2, 1), (4, 2, 2), (5, 2, 3), (usize::MAX, 2, 3)] {
let mut e = Engine::default();
for _ in 0..5 {
e.execute("track.new", &json!({})).unwrap();
}
for _ in 0..3 {
assert!(e.undo());
}
e.mark_clean();
e.set_history_limit(requested);
let limit = requested.max(1);
assert_eq!(e.history_limit, limit);
assert_eq!((e.undo.len(), e.redo.len()), (past, future), "requested {requested}");
assert!(!e.is_dirty(), "history capacity is not a document edit");
assert_eq!(e.session().tracks.len(), 2);
for step in 1..=future {
assert!(e.redo());
assert_eq!(e.session().tracks.len(), 2 + step, "keep the nearest redo entries");
assert!(e.undo.len() + e.redo.len() <= limit);
}
assert!(!e.redo());
for step in 1..=past + future {
assert!(e.undo());
assert_eq!(e.session().tracks.len(), 2 + future - step, "keep the nearest undo entries");
assert!(e.undo.len() + e.redo.len() <= limit);
}
assert!(!e.undo());
for _ in 0..past + future {
assert!(e.redo());
assert!(e.undo.len() + e.redo.len() <= limit);
}
assert!(!e.redo());
}
}
#[test]
fn selecting_locating_and_zooming_leave_the_session_clean() {
let mut e = crate::demo::demo_engine();
@@ -703,6 +798,17 @@ mod dirty_tests {
assert!(!e.is_dirty());
}
/// Unchanged runtime flags do not detach a shared document snapshot.
#[test]
fn matching_live_states_keep_the_document_shared() {
let mut e = Engine::default();
let track = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
e.runtime_session_mut().track_mut(track).unwrap().mixer.record_arm = true;
let snapshot = e.session_arc();
e.keep_runtime_track_states(&snapshot);
assert!(Arc::ptr_eq(&snapshot, &e.session_arc()));
}
#[test]
fn dirty_state_follows_save_undo_and_redo_after_navigation() {
let mut e = Engine::default();
+74 -4
View File
@@ -146,12 +146,20 @@ fn measure_grid(s: &Session, end_unit: i64) -> Vec<(i64, i64, u32, u32)> {
let a = s.tempo.bar_start_tick(bar);
let b = s.tempo.bar_start_tick(bar + 1);
let m = s.tempo.meter_at_tick(a);
let (ua, ub) = (a / UNIT, b / UNIT);
if ub <= ua {
if b <= a {
break;
}
v.push((ua, ub - ua, m.numerator, m.denominator));
let (ua, ub) = (a / UNIT, b / UNIT);
bar += 1;
if ub <= ua {
// A bar shorter than a sixteenth that does not reach the next unit: the following
// bar carries this span, so the grid stays contiguous.
continue;
}
// Bars shorter than a sixteenth are written as one measure of the units they cover, so
// the time signature matches the measure's content.
let (num, den) = if m.ticks_per_bar() < UNIT { (u32::try_from(ub - ua).unwrap_or(1), 16) } else { (m.numerator, m.denominator) };
v.push((ua, ub - ua, num, den));
if ub >= end_unit.max(1) || v.len() >= MAX_MEASURES {
break;
}
@@ -410,7 +418,9 @@ fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<Str
const STAFF_GAP: f64 = 70.0;
const SYSTEM_GAP: f64 = 40.0;
let n_measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
let per = setup.bars_per_system.max(1) as usize;
// The layout range the setup offers (1 to 16): a caller's own value can't multiply the
// barlines drawn per system beyond it.
let per = setup.bars_per_system.clamp(1, 16) as usize;
let systems = n_measures.div_ceil(per).max(1);
let label_w = if setup.show_track_names { 70.0 } else { 0.0 };
let staff_x0 = MARGIN + label_w + 34.0;
@@ -644,6 +654,58 @@ mod tests {
assert!(s.starts_with("<svg") && s.contains("<ellipse"));
}
/// Note onsets (pitch count of every chord that is not a tie continuation).
fn note_starts(parts: &[Part]) -> usize {
parts.iter().flat_map(|p| &p.measures).flat_map(|m| &m.items).filter(|i| !i.tie_stop).map(|i| i.pitches.len()).sum()
}
#[test]
fn bars_shorter_than_a_sixteenth_keep_every_note() {
let base = crate::demo::demo_engine();
let baseline = note_starts(&layout(base.session(), &[]));
assert!(baseline > 0, "demo has notes");
for (n, d) in [(1, 32), (1, 64), (2, 64), (3, 64)] {
for mid in [false, true] {
let mut e = crate::demo::demo_engine();
let tick = if mid { e.session().tempo.bar_start_tick(5) } else { 0 };
e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter");
let parts = layout(e.session(), &[]);
assert!(parts.iter().all(|p| !p.measures.is_empty()), "{n}/{d} mid={mid}: empty part");
assert_eq!(note_starts(&parts), baseline, "{n}/{d} mid={mid}: notes lost");
for p in &parts {
for m in &p.measures {
let units: u32 = m.items.iter().map(|i| i.units).sum();
// The written meter matches the measure's content (divisions are sixteenths).
assert_eq!(units * m.denominator, 16 * m.numerator, "{n}/{d} mid={mid}: meter/content mismatch");
}
}
let setup = ScoreSetup::from_session(e.session());
let x = musicxml(&parts, &setup).expect("musicxml fits");
assert!(x.contains("<pitch>"), "{n}/{d} mid={mid}: musicxml has no notes");
let s = svg(&parts, &setup).expect("svg fits");
assert!(s.contains("<ellipse"), "{n}/{d} mid={mid}: svg has no notes");
}
}
}
#[test]
fn bars_of_a_sixteenth_a_32nd_and_a_64th_in_a_row_keep_a_contiguous_grid() {
let base = crate::demo::demo_engine();
let baseline = note_starts(&layout(base.session(), &[]));
let mut e = crate::demo::demo_engine();
// Bars 5 to 8 are 1/16, 1/32, 1/64 and 3/64 long, then 4/4 resumes.
for (bar, (n, d)) in (5..).zip([(1, 16), (1, 32), (1, 64), (3, 64), (4, 4)]) {
let tick = e.session().tempo.bar_start_tick(bar);
e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter");
}
let grid = measure_grid(e.session(), i64::MAX);
assert_eq!(grid.first().map(|m| m.0), Some(0));
for w in grid.windows(2) {
assert_eq!(w[0].0 + w[0].1, w[1].0, "measures leave no gap or overlap: {w:?}");
}
assert_eq!(note_starts(&layout(e.session(), &[])), baseline, "notes lost");
}
#[test]
fn output_is_bounded_by_byte_budget() {
// A part with many notes must not produce an unbounded document.
@@ -722,6 +784,14 @@ mod tests {
assert!(m.items.iter().any(|i| i.pitches.is_empty()), "the discarded suffix became rests");
}
#[test]
fn bars_per_system_is_held_to_the_supported_range() {
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: u32::MAX, show_track_names: false };
let part = Part { name: "p".into(), bass: false, measures: vec![Measure { numerator: 4, denominator: 4, items: Vec::new() }] };
let out = svg_budgeted(std::slice::from_ref(&part), &setup, 1 << 20).unwrap();
assert_eq!(out.matches("stroke-width=\"1\"").count(), 16, "sixteen barlines, not billions");
}
#[test]
fn oversized_title_is_refused_by_the_header_check() {
// An oversized part name/title must be caught by the header check even with no measures:
+32
View File
@@ -123,6 +123,38 @@ fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A WAV bounce is a Broadcast Wave stamped with where it starts on the session's timecode (`bext`
/// TimeReference, samples since midnight), as other DAWs write it, so post tools can place it.
#[test]
fn wav_bounces_carry_their_timecode_position_as_a_bwf_time_reference() {
let mut e = soundcraft_engine::demo::demo_engine();
let sr = u64::from(e.session().sample_rate.hz());
// The session starts at 01:00:00:00; the bounce at 2 s into it.
e.session_mut().timecode_start = (3600 * sr) as i64;
let (start, end) = (2 * sr, 3 * sr);
let dir = scratch("bounce-bext");
let stamp = |path: &std::path::Path| {
let ext = path.extension().and_then(|x| x.to_str());
soundcraft_audio_io::probe(&std::fs::read(path).unwrap(), ext).unwrap().bwf.map(|b| b.time_reference)
};
let mix = dir.join("Mix.wav");
e.execute("file.bounce_mix", &serde_json::json!({"path": mix, "start": start, "end": end})).unwrap();
assert_eq!(stamp(&mix), Some(3600 * sr + start), "mix");
let stems = dir.join("stems");
let files = e.execute("file.bounce_stems", &serde_json::json!({"dir": stems, "start": start, "end": end})).unwrap();
let files = files["files"].as_array().unwrap();
assert!(!files.is_empty());
for f in files {
let f = std::path::PathBuf::from(f.as_str().unwrap());
assert_eq!(stamp(&f), Some(3600 * sr + start), "{}", f.display());
}
// A session that starts at zero still says where the bounce starts.
e.session_mut().timecode_start = 0;
e.execute("file.bounce_mix", &serde_json::json!({"path": mix, "start": start, "end": end})).unwrap();
assert_eq!(stamp(&mix), Some(start));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dropping_an_unfinished_bounce_cancels_it_and_keeps_the_old_file() {
let e = soundcraft_engine::demo::demo_engine();
+123
View File
@@ -220,6 +220,129 @@ fn relative_grid_pastes_keep_the_offset_from_the_grid() {
assert_eq!(clips(&e, t).last(), Some(&(5 * step, 1000)));
}
/// A relative paste restores the insertion point even when Cut creates a new boundary there.
#[test]
fn relative_grid_cut_pastes_back_at_its_insertion_boundary() {
let (mut e, t, _) = long("relative_grid", &[(1_000, 2_000, 0)]);
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 1_500, "end": 2_000}));
run(&mut e, "edit.paste", json!({"tracks": [t.0]}));
assert_eq!(e.session().edit.selection.start, 1_500);
assert!(clips(&e, t).contains(&(1_500, 500)), "{:?}", clips(&e, t));
}
/// Moving a group excludes all its clips from Clips/Markers snap candidates.
#[test]
fn clips_markers_grid_ignores_every_clip_being_moved() {
let (mut e, _, ids) = long("grid", &[(1_000, 1_000, 0), (4_000, 500, 2_000)]);
e.session_mut().add_marker("Target", soundcraft_model::MarkerKind::Marker, 2_500, 2_500);
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "to": 1_000}));
assert_eq!([start(&e, ids[0]), start(&e, ids[1])], [1_000, 4_000]);
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "to": 2_200}));
assert_eq!(start(&e, ids[0]), 2_500);
assert_eq!(start(&e, ids[1]), 5_500);
}
/// Zero and clamped moves preserve overlaps while off-grid quantization still changes position.
#[test]
fn zero_distance_move_keeps_overlapping_material_and_quantize_still_moves() {
let (mut e, t, ids) = long("slip", &[(1_000, 1_000, 0), (1_500, 1_000, 2_000)]);
let before = material(&e, t);
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": 0}));
assert_eq!(material(&e, t), before);
run(&mut e, "edit.select", json!({"clips": [ids[0].0]}));
run(&mut e, "clip.quantize_to_grid", json!({}));
assert_eq!(start(&e, ids[0]), 0);
let (mut e, t, ids) = long("slip", &[(0, 1_000, 0), (500, 1_000, 2_000)]);
let before = material(&e, t);
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": -1_000}));
assert_eq!(material(&e, t), before);
}
/// A vertical move remains a real edit and snaps its off-grid start on the destination track.
#[test]
fn zero_horizontal_grid_move_to_another_track_still_snaps() {
let (mut e, _, [_, clip]) = engine("grid");
let destination = new_track(&mut e);
run(&mut e, "edit.move_clips", json!({"clips": [clip.0], "to": 2_000, "track": destination.0}));
let (track, moved) = e.session().find_clip(clip).unwrap();
assert_eq!(track, destination);
assert_eq!(moved.start, 0);
}
/// Nudge and quantization must preserve overlapping material when the clip cannot move.
#[test]
fn stationary_nudges_and_quantization_preserve_overlaps() {
for (command, params) in [("edit.nudge", json!({"direction": -1})), ("clip.quantize_to_grid", json!({}))] {
let (mut e, t, ids) = long("slip", &[(0, 1_000, 0), (500, 1_000, 2_000)]);
run(&mut e, "edit.select", json!({"clips": [ids[0].0]}));
let before = e.session().track(t).unwrap().clips().to_vec();
let result = e.execute(command, &params).unwrap();
assert_eq!(e.session().track(t).unwrap().clips(), before, "{command}");
if command == "edit.nudge" {
assert_eq!(result["moved"], 0);
}
}
}
/// Every Nudge scale shifts clips and insertion across a tempo change and drop-frame rates.
#[test]
fn every_nudge_scale_moves_by_its_selected_increment() {
use soundcraft_time::{FrameRate, SampleRate, TICKS_PER_QUARTER};
for rate in [FrameRate::Fps30, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
for at in [48_000, 144_000] {
let cases = [
(json!("1/4"), if at < 96_000 { 24_000 } else { 32_000 }),
(json!({"seconds": 0.5}), 24_000),
(json!({"timecode": "5_frames"}), rate.samples_at_frame(5, SampleRate::HZ_48000)),
(json!({"feet_frames": "foot"}), rate.samples_at_frame(16, SampleRate::HZ_48000)),
(json!({"samples": 100}), 100),
];
for (value, expected) in cases {
let (mut e, track, ids) = long("slip", &[(at, 1_000, 0)]);
e.session_mut().frame_rate = rate;
e.session_mut().tempo.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap();
run(&mut e, "edit.select", json!({"tracks": [track.0], "clips": [ids[0].0], "start": at, "end": at}));
run(&mut e, "view.nudge", json!({"value": value, "follow_main": false}));
let result = e.execute("edit.nudge", &json!({"direction": 1})).unwrap();
assert_eq!(result["by"], expected, "{rate:?} at {at}");
assert_eq!(result["moved"], 1);
assert_eq!(start(&e, ids[0]), at + expected);
assert_eq!(e.session().edit.selection.start, at + expected);
}
}
}
}
/// Separation refuses oversized numeric grids before splitting any material.
#[test]
fn separate_on_grid_refuses_a_truncated_boundary_set() {
let (mut e, t, _) = long("grid", &[(0, 20_000, 0)]);
run(&mut e, "view.grid", json!({"value": {"samples": 1}, "follow_main": false}));
assert!(e.execute("edit.separate_on_grid", &json!({"tracks": [t.0], "start": 0, "end": 10_002})).is_err());
assert_eq!(clips(&e, t), [(0, 20_000)]);
}
/// Display thinning must never hide an oversized Clips/Markers separation request.
#[test]
fn separate_on_grid_refuses_dense_markers_before_changing_clips() {
let (mut e, track, _) = long("grid", &[(0, 100_000, 0)]);
let s = e.session_mut();
for at in 1..=10_001 {
s.add_marker("Boundary", soundcraft_model::MarkerKind::Marker, at, at);
}
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
let before = e.session().clone();
let undo_len = e.undo_history().len();
let error = e.execute("edit.separate_on_grid", &json!({"tracks": [track.0], "start": 0, "end": 100_000})).unwrap_err();
assert!(error.to_string().contains("10,000"), "{error}");
assert_eq!(e.session(), &before);
assert_eq!(e.undo_history().len(), undo_len);
}
#[test]
fn shuffle_moves_keep_crossfade_partners_whole() {
let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]);
+1
View File
@@ -13,6 +13,7 @@ mod long_renders;
mod midi_offset;
mod min_secs_with_timecode_counter;
mod mix_required_params;
mod new_track_rows;
mod random_edits;
mod recording;
mod session_transport;
+68
View File
@@ -28,3 +28,71 @@ fn midi_track_offset_moves_the_notes_like_moving_the_clip() {
e.execute("edit.move_clips", &json!({"clip": clip, "by": 24_000})).unwrap();
assert!(render(&e) == offset, "the offset must match moving the clip by 24000 samples");
}
#[test]
fn export_midi_leaves_out_what_a_trimmed_clip_no_longer_plays() {
let mut e = Engine::default();
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Trim", "instrument": "subtractive_synth"})).unwrap();
let track = r["tracks"][0].as_u64().unwrap();
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
let clip = r["clip"].as_u64().unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0, "length_ticks": 960, "velocity": 100})).unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 62, "start_ticks": 800, "length_ticks": 960, "velocity": 100})).unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 64, "start_ticks": 2000, "length_ticks": 960, "velocity": 100})).unwrap();
// 24000 samples is 960 ticks at 120 bpm.
let id = e.session().tracks[0].clips()[0].id;
e.session_mut().find_clip_mut(id).unwrap().trim_end_to(24_000);
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
let notes = &smf.tracks[0].sequence.notes;
assert_eq!(notes.len(), 2, "only notes starting inside the clip are exported");
let late = notes.iter().find(|n| n.pitch == 62).unwrap();
assert_eq!(late.start + late.length, 960, "a note running past the clip end is cut at it");
}
#[test]
fn export_midi_releases_a_sustain_held_past_the_trim() {
use soundcraft_midi::{CtrlEvent, CtrlKind};
let mut e = Engine::default();
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Sus", "instrument": "subtractive_synth"})).unwrap();
let track = r["tracks"][0].as_u64().unwrap();
e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
let id = e.session().tracks[0].clips()[0].id;
{
let clip = e.session_mut().find_clip_mut(id).unwrap();
if let soundcraft_model::ClipContent::Midi { sequence } = &mut clip.content {
let cc = |tick, channel, value| CtrlEvent { tick, channel, kind: CtrlKind::Cc(64), value };
sequence.ctrls = vec![cc(100, 0, 127), cc(200, 1, 127), cc(300, 1, 0), cc(2000, 0, 0)];
}
clip.trim_end_to(24_000);
}
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
let ctrls = &smf.tracks[0].sequence.ctrls;
let sus: Vec<_> = ctrls.iter().filter(|c| c.kind == CtrlKind::Cc(64)).collect();
assert!(sus.iter().any(|c| c.channel == 0 && c.tick == 960 && c.value == 0), "{sus:?}");
assert!(!sus.iter().any(|c| c.channel == 1 && c.tick == 960), "released channel gets no extra event: {sus:?}");
}
#[test]
fn export_midi_keeps_the_notes_of_a_clip_trimmed_at_both_ends_in_place() {
let mut e = Engine::default();
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Both", "instrument": "subtractive_synth"})).unwrap();
let track = r["tracks"][0].as_u64().unwrap();
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
let clip = r["clip"].as_u64().unwrap();
for (pitch, start) in [(60, 0), (62, 1000), (64, 2000)] {
e.execute("midi.note_add", &json!({"clip": clip, "pitch": pitch, "start_ticks": start, "length_ticks": 960, "velocity": 100})).unwrap();
}
// 24000 samples is 960 ticks and 72000 samples is 2880 ticks at 120 bpm.
e.execute("edit.trim_start_to_insertion", &json!({"tracks": [track], "at": 24_000})).unwrap();
e.execute("edit.trim_end_to_insertion", &json!({"tracks": [track], "at": 72_000})).unwrap();
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
let mut notes: Vec<_> = smf.tracks[0].sequence.notes.iter().map(|n| (n.pitch, n.start, n.length)).collect();
notes.sort_unstable();
// The note cut by the start trim is gone; the others keep their place and the last stops at the end.
assert_eq!(notes, vec![(62, 1000, 960), (64, 2000, 880)]);
}
+82
View File
@@ -0,0 +1,82 @@
use serde_json::json;
use soundcraft_engine::Engine;
use soundcraft_model::{ChannelFormat, TrackKind};
/// Mixed rows preserve their order and share one selection and history step.
#[test]
fn mixed_rows_create_in_order_select_every_track_and_undo_in_one_step() {
let mut e = Engine::default();
e.execute("track.new", &json!({"name": "Existing"})).unwrap();
let before = e.session().to_json().unwrap();
let result = e
.execute(
"track.new",
&json!({"rows": [
{"count": 2, "kind": "audio", "format": "mono", "name": "Dialogue", "timebase": "samples"},
{"kind": "aux", "format": "stereo", "name": "Reverb", "timebase": "samples"},
{"kind": "instrument", "format": "stereo", "name": "Keys", "timebase": "ticks", "instrument": "subtractive_synth"},
{"kind": "folder", "format": "stereo", "name": "Music"}
]}),
)
.unwrap();
let s = e.session();
let created = &s.tracks[1..];
assert_eq!(created.len(), 5);
assert_eq!(
created.iter().map(|t| t.kind).collect::<Vec<_>>(),
[TrackKind::Audio, TrackKind::Audio, TrackKind::Aux, TrackKind::Instrument, TrackKind::Folder]
);
assert_eq!(
created.iter().map(|t| t.format).collect::<Vec<_>>(),
[ChannelFormat::Mono, ChannelFormat::Mono, ChannelFormat::Stereo, ChannelFormat::Stereo, ChannelFormat::Stereo]
);
assert!(!created[0].ticks_timebase);
assert!(created[3].ticks_timebase && created[3].instrument.is_some());
assert_eq!(s.busses.len(), 1);
let ids = created.iter().map(|t| t.id).collect::<Vec<_>>();
assert_eq!(s.edit.selected_tracks, ids);
assert_eq!(result["tracks"], json!(ids));
let after = s.to_json().unwrap();
assert!(e.undo());
assert_eq!(e.session().to_json().unwrap(), before);
assert!(e.redo());
assert_eq!(e.session().to_json().unwrap(), after);
}
/// A failure in a later row rolls the complete command back atomically.
#[test]
fn invalid_later_row_leaves_no_partial_creation_or_undo_step() {
for field in [json!({"kind": "unknown"}), json!({"format": "unknown"})] {
let mut e = Engine::default();
let before = e.session().to_json().unwrap();
assert!(e.execute("track.new", &json!({"rows": [{"kind": "folder"}, field]})).is_err());
assert_eq!(e.session().to_json().unwrap(), before);
assert!(!e.can_undo());
assert!(e.journal.is_empty());
}
}
/// Row structure and aggregate track count reject malformed or oversized requests.
#[test]
fn row_shape_and_total_size_are_bounded() {
for rows in [json!(null), json!({}), json!([]), json!([true]), json!([{"rows": []}]), json!([{"count": 128}, {}]), json!(vec![json!({}); 129])] {
let mut e = Engine::default();
assert!(e.execute("track.new", &json!({"rows": rows})).is_err(), "{rows}");
assert!(e.session().tracks.is_empty());
assert!(!e.can_undo());
}
let mut e = Engine::default();
e.execute("track.new", &json!({"rows": [{"count": 64}, {"count": 64}]})).unwrap();
assert_eq!(e.session().tracks.len(), 128);
}
/// Calls without `rows` retain the original defaults and per-row count clamp.
#[test]
fn legacy_single_row_parameters_keep_their_defaults_and_count_clamp() {
let mut e = Engine::default();
e.execute("track.new", &json!({"kind": "aux", "count": -1})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Stereo);
e.execute("track.new", &json!({"count": 200})).unwrap();
assert_eq!(e.session().tracks.len(), 129);
}
+22 -6
View File
@@ -1,4 +1,6 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::hash_map::Entry;
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -108,14 +110,28 @@ impl Sequence {
self.ctrls.sort_by_key(|c| c.tick);
let mut kept: Vec<CtrlEvent> = Vec::with_capacity(self.ctrls.len());
// The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found
// without rescanning the tick's events (a tick can hold many thousands of distinct keys).
// An entry from an earlier tick counts as absent; keeping the tick instead of clearing the
// map per tick keeps the work per event constant after a dense tick.
let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new();
for c in self.ctrls.drain(..) {
// Within a tick, replace an earlier event of the same channel+kind.
// Only events at the same tick (the tail of `kept`) can match.
let slot =
kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && kind_key(k.kind) == kind_key(c.kind));
match slot {
Some(slot) => *slot = c,
None => kept.push(c),
match last_at.entry((c.channel, kind_key(c.kind))) {
Entry::Occupied(mut e) => {
let (tick, idx) = *e.get();
match kept.get_mut(idx) {
Some(slot) if tick == c.tick => *slot = c,
_ => {
e.insert((c.tick, kept.len()));
kept.push(c);
}
}
}
Entry::Vacant(e) => {
e.insert((c.tick, kept.len()));
kept.push(c);
}
}
}
self.ctrls = kept;
+69
View File
@@ -222,3 +222,72 @@ fn sequence_serde_round_trip() {
let json = serde_json::to_string(&seq).unwrap();
assert_eq!(serde_json::from_str::<Sequence>(&json).unwrap(), seq);
}
#[test]
fn remove_duplicates_handles_a_large_same_tick_controller_group() {
// Every channel x controller number at one tick, twice: the second copy of each wins. A
// rescan of the tick's kept events per incoming event would make this quadratic.
let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect();
let ctrls = [0, 1]
.iter()
.flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass }))
.collect();
let mut seq = Sequence { notes: Vec::new(), ctrls };
let start = std::time::Instant::now();
assert_eq!(seq.remove_duplicates(), keys.len());
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
assert_eq!(seq.ctrls.len(), keys.len());
assert!(seq.ctrls.iter().all(|c| c.value == 1));
}
#[test]
fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() {
// The straightforward per-tick rescan the indexed version replaced, kept as an oracle.
fn reference(mut ctrls: Vec<CtrlEvent>) -> Vec<CtrlEvent> {
ctrls.sort_by_key(|c| c.tick);
let key = |k: CtrlKind| match k {
CtrlKind::Cc(n) => (0, n),
CtrlKind::PitchBend => (1, 0),
CtrlKind::ChannelPressure => (2, 0),
CtrlKind::PolyPressure(p) => (3, p),
CtrlKind::Program => (4, 0),
};
let mut kept: Vec<CtrlEvent> = Vec::new();
for c in ctrls {
match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) {
Some(slot) => *slot = c,
None => kept.push(c),
}
}
kept
}
// Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with
// other keys) and across ticks; unsorted input from a fixed xorshift stream.
let mut state = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let kinds = |r: u64| match r % 5 {
0 => CtrlKind::Cc((r >> 8) as u8 % 4),
1 => CtrlKind::PitchBend,
2 => CtrlKind::ChannelPressure,
3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4),
_ => CtrlKind::Program,
};
for round in 0..200 {
let mut ctrls = Vec::new();
for i in 0..(next() % 64) {
let r = next();
ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 });
}
let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() };
let removed = seq.remove_duplicates();
let expected = reference(ctrls.clone());
assert_eq!(seq.ctrls, expected, "round {round}");
assert_eq!(removed, ctrls.len() - expected.len(), "round {round}");
}
}
+16
View File
@@ -68,7 +68,23 @@ pub struct NewInstance {
/// Creates a plugin (built-in or hosted), restores `state` (if any) and prepares it. Expensive
/// for third-party plugins: in realtime use, call it on the control thread.
pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_block: usize, ch: usize) -> Option<NewInstance> {
create_instance_for(id, state, sample_rate, max_block, ch, false)
}
/// [`create_instance`] for an engine that renders offline (`offline`) or in realtime: the plugin
/// is told which before it is prepared ([`Plugin::set_offline`]).
pub(crate) fn create_instance_for(
id: &str,
state: Option<&[u8]>,
sample_rate: f32,
max_block: usize,
ch: usize,
offline: bool,
) -> Option<NewInstance> {
let mut plugin = create_plugin(id)?;
if offline {
plugin.set_offline(true);
}
let refused_state = state.is_some_and(|st| !plugin.load_state(st));
if refused_state {
log::warn!("{id}: the plugin did not accept its stored state");
+10 -1
View File
@@ -144,6 +144,8 @@ pub struct MixEngine {
pub metronome: bool,
/// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`.
external: bool,
/// An offline render: the plugins `sync` creates are told so ([`Plugin::set_offline`]).
offline: bool,
/// Instances handed over by the host, waiting for `sync` to place them.
adopt: HashMap<(TrackId, usize), PreparedInstance>,
/// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]).
@@ -177,6 +179,7 @@ impl MixEngine {
recording: false,
metronome: false,
external: false,
offline: false,
adopt: HashMap::new(),
retired: Vec::new(),
work: Vec::new(),
@@ -189,6 +192,11 @@ impl MixEngine {
self.external = on;
}
/// Offline render (a bounce): plugins created from now on are told they render offline.
pub fn set_offline(&mut self, on: bool) {
self.offline = on;
}
/// Takes instances created off the audio thread; the next render's `sync` places them.
/// An earlier instance waiting for the same slot is retired.
pub fn adopt(&mut self, instances: Vec<PreparedInstance>) {
@@ -271,7 +279,8 @@ impl MixEngine {
if self.main.len() != main_ch {
self.main = vec![vec![0.0; mb]; main_ch];
}
let mut cx = SlotCtx { sr: self.sample_rate, mb, external: self.external, adopt: &mut self.adopt, retired: &mut self.retired };
let mut cx =
SlotCtx { sr: self.sample_rate, mb, external: self.external, offline: self.offline, adopt: &mut self.adopt, retired: &mut self.retired };
let gone: Vec<TrackId> = self.strips.keys().filter(|id| s.track(**id).is_none()).copied().collect();
for id in gone {
self.meters.remove(&id);
+8 -2
View File
@@ -46,12 +46,20 @@ pub(crate) fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize
rtp("delay", 0.0).clamp(-1e7, 1e7) as i64,
)
};
// The block in clip ticks, one tick wider on each side so rounding never drops an event.
let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)
..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);
for n0 in &sequence.notes {
let mut n = *n0;
n.velocity = (i64::from(n.velocity) + dv).clamp(1, 127) as u8;
n.pitch = (i64::from(n.pitch) + dp).clamp(0, 127) as u8;
n.length = ((n.length as f64 * dur) as i64).max(1);
n.start = n.start.saturating_add(delay).max(0);
// A note whose on and off both fall outside the block sends nothing: skip it before
// converting its positions, so a long sequence costs a comparison per note per block.
if n.start > *near.end() || n.start.saturating_add(n.length) < *near.start() {
continue;
}
let on = s.tempo.tick_to_samples(base.saturating_add(n.start), sr);
let off = s.tempo.tick_to_samples(base.saturating_add(n.start).saturating_add(n.length), sr).min(clip.end());
if on >= clip.end() {
@@ -68,8 +76,6 @@ pub(crate) fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize
held.retain(|p| *p != n.pitch);
}
}
let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)
..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);
// Events before tick 0 are what a start trim hid: they don't play.
for c in sequence.ctrls.iter().filter(|c| c.tick >= 0) {
let tick = c.tick.saturating_add(delay).max(0);
+2 -1
View File
@@ -57,7 +57,8 @@ impl RangeRender {
/// Renders the main mix over `range`, or `bus` when one is named.
pub fn new(s: &Session, bus: Option<BusId>, range: Range, block: usize) -> Self {
let nch = source_channels(s, bus);
let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
eng.set_offline(true);
let tmp = vec![vec![0.0f32; eng.max_block()]; nch];
let len = if nch == 0 { 0 } else { usize::try_from(range.len().max(0)).unwrap_or(0) };
RangeRender { eng, bus, tmp, start: range.start, done: 0, total: len, latency: 0 }
+108 -24
View File
@@ -46,7 +46,8 @@ pub(crate) fn processing_order(s: &Session) -> Vec<usize> {
} else {
// A cycle keeps the existing bounded ordering for the entire graph, including any
// independent acyclic branches. Feedback does not have a finite longest-path depth.
auxes.sort_by_key(|&i| bounded_aux_depth(s, i));
let depths = bounded_aux_depths(s);
auxes.sort_by_key(|&i| depths.get(i).copied().unwrap_or(0));
}
order.extend(auxes);
order
@@ -115,32 +116,38 @@ fn aux_output_busses(t: &Track) -> impl Iterator<Item = BusId> + '_ {
.filter_map(|route| if let Route::Bus(bus) = route { Some(*bus) } else { None })
}
/// The previous bounded depth calculation, retained unchanged for any cyclic graph.
fn bounded_aux_depth(s: &Session, i: usize) -> usize {
let mut d = 0;
let mut cur = vec![i];
for _ in 0..8 {
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
let feeders: Vec<usize> = auxes_feeding(s, &inputs);
if feeders.is_empty() {
/// Hop cap for the bounded depth used when the aux graph has a cycle.
const MAX_BOUNDED_DEPTH: usize = 8;
/// For each track, the bounded depth used for a cyclic graph: the length of the longest chain of
/// Aux/Folder feeders into it (through a bus its input reads and the feeder's output or a send
/// writes), capped at [`MAX_BOUNDED_DEPTH`] hops. One pass per hop over the tracks and their bus
/// writes, so the cost is linear in the routing, not a fresh feeder search per track per
/// sort comparison.
fn bounded_aux_depths(s: &Session) -> Vec<usize> {
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
// Tracks with a feeder chain of the current length ending at them (length 0: every aux).
let mut reached: Vec<bool> = s.tracks.iter().map(is_aux).collect();
let mut depths = vec![0usize; s.tracks.len()];
let mut fed = std::collections::HashSet::<BusId>::new();
for hop in 1..=MAX_BOUNDED_DEPTH {
fed.clear();
for (t, _) in s.tracks.iter().zip(&reached).filter(|(t, r)| **r && is_aux(t)) {
fed.extend(aux_output_busses(t));
}
let mut any = false;
for ((t, r), d) in s.tracks.iter().zip(reached.iter_mut()).zip(depths.iter_mut()) {
*r = matches!(&t.mixer.input, Route::Bus(b) if fed.contains(b));
if *r {
*d = hop;
any = true;
}
}
if !any {
break;
}
d += 1;
cur = feeders;
}
d
}
fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
s.tracks
.iter()
.enumerate()
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
.filter(|(_, t)| {
inputs.iter().any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
})
.map(|(i, _)| i)
.collect()
depths
}
/// Implicit solo: an aux is not muted when a soloed track feeds its input bus.
@@ -204,4 +211,81 @@ mod processing_order_tests {
// Both deepest independent returns tie with the cycle at the legacy eight-hop cap.
assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 0, 1, 10]);
}
/// The per-track feeder search `bounded_aux_depths` replaced, kept as its reference.
fn reference_depth(s: &Session, i: usize) -> usize {
let mut d = 0;
let mut cur = vec![i];
for _ in 0..MAX_BOUNDED_DEPTH {
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
let feeders: Vec<usize> = s
.tracks
.iter()
.enumerate()
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
.filter(|(_, t)| {
inputs
.iter()
.any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
})
.map(|(i, _)| i)
.collect();
if feeders.is_empty() {
break;
}
d += 1;
cur = feeders;
}
d
}
#[test]
fn bounded_depths_match_the_per_track_feeder_search() {
// A small pseudo-random graph of auxes, folders and audio tracks with cycles, joins,
// sends and unrouted inputs.
let mut s = Session::default();
let busses: Vec<_> = (0..6).map(|i| s.add_bus(&format!("B{i}"), ChannelFormat::Stereo)).collect();
let mut x = 12345u32;
let mut next = |m: usize| {
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
(x >> 16) as usize % m
};
for i in 0..30 {
let kind = [TrackKind::Aux, TrackKind::Folder, TrackKind::Audio][next(3)];
let id = s.add_track(kind, ChannelFormat::Stereo, None);
let pick = |n: usize| if n < busses.len() { Route::Bus(busses[n]) } else { Route::Main };
let (input, output, send) = (pick(next(8)), pick(next(8)), pick(next(8)));
let t = s.track_mut(id).unwrap();
t.mixer.input = input;
t.mixer.output = output;
if i % 2 == 0 {
t.mixer.sends[0] = Some(SendSlot::new(send));
}
}
let depths = bounded_aux_depths(&s);
for (i, (t, depth)) in s.tracks.iter().zip(&depths).enumerate() {
if matches!(t.kind, TrackKind::Aux | TrackKind::Folder) {
assert_eq!(*depth, reference_depth(&s, i), "track {i}");
}
}
assert!(depths.contains(&MAX_BOUNDED_DEPTH));
}
#[test]
fn a_large_cyclic_graph_orders_quickly() {
// Thousands of auxes all reading and writing one bus: every aux feeds every other.
let mut s = Session::default();
let bus = s.add_bus("Loop", ChannelFormat::Stereo);
for _ in 0..3000 {
let id = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
let t = s.track_mut(id).unwrap();
t.mixer.input = Route::Bus(bus);
t.mixer.output = Route::Bus(bus);
t.mixer.sends[0] = Some(SendSlot::new(Route::Bus(bus)));
}
let start = std::time::Instant::now();
let order = processing_order(&s);
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
assert_eq!(order, (0..3000).collect::<Vec<_>>());
}
}
+128 -8
View File
@@ -1,6 +1,6 @@
use super::automation::{GainRamp, transport_at, volume_db_at};
use super::clips::{ClipFx, clip_fx_params, render_clip};
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance, is_third_party};
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance_for, is_third_party};
use super::midi::{chase_controllers, schedule_notes};
use super::pan::{PanSpec, apply_matrix, build_matrix, identity_matrix};
use super::routing::receives_solo;
@@ -24,6 +24,9 @@ pub(crate) struct PluginSlot {
pub(crate) plugin: Box<dyn Plugin>,
/// Last values pushed to the plugin, in the insert's parameter-map order.
applied: Vec<f32>,
/// Scratch for `apply_params`: indexes of the track's automation lanes for this slot, kept
/// so the audio thread reuses its allocation.
lanes: Vec<usize>,
pub(crate) failed: bool,
}
@@ -157,6 +160,8 @@ pub(crate) struct SlotCtx<'a> {
pub(crate) sr: f32,
pub(crate) mb: usize,
pub(crate) external: bool,
/// The engine renders offline: plugins it creates are told so.
pub(crate) offline: bool,
pub(crate) adopt: &'a mut HashMap<(TrackId, usize), PreparedInstance>,
pub(crate) retired: &'a mut Vec<Box<dyn Plugin>>,
}
@@ -189,9 +194,9 @@ impl SlotCtx<'_> {
}
} else {
let state = if third { ins.state_bytes() } else { None };
create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin)
create_instance_for(&ins.plugin, state.as_deref(), self.sr, self.mb, ch, self.offline).map(|n| n.plugin)
}?;
Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false })
Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false })
}
}
@@ -491,21 +496,40 @@ pub(crate) fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert
let Ok(slot_u8) = u8::try_from(i) else {
return apply_values(slot, ins, |_, v| v);
};
let automated = t.mixer.automation_mode.reads()
&& t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());
let lanes = &t.automation;
let name = |j: usize| match lanes.get(j).map(|l| &l.param) {
Some(AutoParam::Plugin { param, .. }) => param.as_str(),
_ => "",
};
// This slot's lanes, ordered by parameter name (the parameter map's order) and keeping only
// the first lane per name (the one a lookup by name finds). Matching them to the parameters
// in one merge costs O(lanes log lanes + params) per block, not O(lanes x params).
let mut order = std::mem::take(&mut slot.lanes);
order.clear();
if t.mixer.automation_mode.reads() {
order
.extend(lanes.iter().enumerate().filter(|(_, l)| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8)).map(|(j, _)| j));
if !order.iter().any(|&j| lanes.get(j).is_some_and(|l| !l.points.is_empty())) {
order.clear();
}
order.sort_unstable_by(|&a, &b| name(a).cmp(name(b)).then(a.cmp(&b)));
order.dedup_by(|later, kept| name(*later) == name(*kept));
}
let mut next = order.iter().copied().peekable();
apply_values(slot, ins, |k, val| {
if automated
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
while next.next_if(|&j| name(j) < k).is_some() {}
if let Some(l) = next.peek().filter(|&&j| name(j) == k).and_then(|&j| lanes.get(j))
&& !l.points.is_empty()
{
return l.value_at(pos, val);
}
val
});
slot.lanes = order;
}
/// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time.
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) {
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, mut value: impl FnMut(&str, f32) -> f32) {
if slot.applied.len() != ins.params.len() {
slot.applied = vec![f32::NAN; ins.params.len()];
}
@@ -517,3 +541,99 @@ fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: im
}
}
}
#[cfg(test)]
mod apply_params_tests {
use super::*;
use soundcraft_model::{AutomationLane, Insert};
use std::sync::Mutex;
/// A built-in gain that logs every parameter set.
struct Recorder {
inner: Box<dyn Plugin>,
sets: Arc<Mutex<Vec<(String, f32)>>>,
}
impl Plugin for Recorder {
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
self.inner.info()
}
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
self.inner.prepare(sr, mb, ch);
}
fn reset(&mut self) {
self.inner.reset();
}
fn set_param(&mut self, id: &str, v: f32) -> bool {
self.sets.lock().unwrap().push((id.to_string(), v));
true
}
fn param(&self, id: &str) -> Option<f32> {
self.inner.param(id)
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
self.inner.process(io, frames);
}
}
fn recorder() -> (PluginSlot, Arc<Mutex<Vec<(String, f32)>>>) {
let sets = Arc::new(Mutex::new(Vec::new()));
let plugin = Box::new(Recorder { inner: soundcraft_dsp::create("gain").unwrap(), sets: Arc::clone(&sets) });
(PluginSlot { id: "gain".into(), ch: 2, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false }, sets)
}
fn lane(slot: u8, param: &str, point: Option<f32>) -> AutomationLane {
let mut l = AutomationLane::new(AutoParam::Plugin { slot, param: param.into() });
if let Some(v) = point {
l.set_point(0, v);
}
l
}
#[test]
fn first_lane_per_param_of_this_slot_drives_it() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
let tr = s.track_mut(t).unwrap();
tr.automation = vec![
lane(0, "c", None),
lane(1, "a", Some(5.0)),
lane(0, "b", Some(9.0)),
lane(0, "c", Some(7.0)),
lane(0, "zz", Some(1.0)),
lane(0, "b", Some(8.0)),
];
let mut ins = Insert::new("gain");
for (k, v) in [("a", 1.0), ("b", 2.0), ("c", 3.0), ("d", 4.0)] {
ins.params.insert(k.into(), v);
}
let (mut slot, sets) = recorder();
apply_params(&mut slot, &ins, tr, 0, 0);
let got = sets.lock().unwrap().clone();
let want: Vec<(String, f32)> = [("a", 1.0), ("b", 9.0), ("c", 3.0), ("d", 4.0)].iter().map(|(k, v)| (k.to_string(), *v)).collect();
assert_eq!(got, want);
// Unchanged values are not sent again.
apply_params(&mut slot, &ins, tr, 0, 0);
assert_eq!(sets.lock().unwrap().len(), 4);
}
#[test]
fn many_params_and_lanes_apply_without_a_quadratic_search() {
let n = 40_000;
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
let tr = s.track_mut(t).unwrap();
tr.automation = (0..n).rev().map(|i| lane(0, &format!("p{i:06}"), Some(i as f32))).collect();
let mut ins = Insert::new("gain");
for i in 0..n {
ins.params.insert(format!("p{i:06}"), -1.0);
}
let (mut slot, sets) = recorder();
let start = std::time::Instant::now();
apply_params(&mut slot, &ins, tr, 0, 0);
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
let got = sets.lock().unwrap();
assert_eq!(got.len(), n);
assert!(got.iter().all(|(k, v)| k == &format!("p{:06}", *v as usize)));
}
}
+4 -2
View File
@@ -502,9 +502,11 @@ pub struct Mixer {
pub solo: bool,
#[serde(default)]
pub solo_safe: bool,
#[serde(default)]
/// Live record-enable state. Runtime-only: opening a session must never arm an input.
#[serde(skip)]
pub record_arm: bool,
#[serde(default)]
/// Live input-monitor state. Runtime-only: opening a session must never enable monitoring.
#[serde(skip)]
pub input_monitor: bool,
#[serde(default)]
pub phase_invert: bool,
+375 -4
View File
@@ -5,7 +5,7 @@ use crate::{
Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool,
TRACK_COLORS, Track, TrackId, TrackKind, VideoSource,
};
use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat};
use soundcraft_time::{FrameRate, GridScale, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat};
/// Native session file extension.
pub const SESSION_EXTENSION: &str = "scraft";
@@ -147,6 +147,7 @@ pub struct ZoomState {
}
impl Default for ZoomState {
/// Starts with a practical overview zoom and five reusable preset levels.
fn default() -> Self {
ZoomState { samples_per_px: 1024.0, scroll: 0, waveform_zoom: 1.0, presets: [64.0, 256.0, 1024.0, 4096.0, 16384.0] }
}
@@ -179,6 +180,15 @@ pub struct EditState {
pub playhead: Samples,
pub grid: GridValue,
pub nudge: GridValue,
/// Grid/Nudge time scales follow the main counter when enabled.
#[serde(default)]
pub grid_follow_main: bool,
#[serde(default)]
pub nudge_follow_main: bool,
/// Grid lines and snapping use visible clip boundaries and memory locations while the numeric
/// Grid value is retained for when this mode is left.
#[serde(default)]
pub grid_clips_markers: bool,
#[serde(default = "yes")]
pub grid_lines: bool,
pub main_counter: TimeFormat,
@@ -244,6 +254,7 @@ fn yes() -> bool {
}
impl Default for EditState {
/// Starts the Edit window in Slip mode with independent Grid and Nudge scales.
fn default() -> Self {
EditState {
tool: Tool::Smart,
@@ -255,6 +266,9 @@ impl Default for EditState {
playhead: 0,
grid: GridValue::default(),
nudge: GridValue::Seconds(1.0),
grid_follow_main: false,
nudge_follow_main: false,
grid_clips_markers: false,
grid_lines: true,
main_counter: TimeFormat::MinSecs,
sub_counter: Some(TimeFormat::Samples),
@@ -490,6 +504,138 @@ impl Session {
soundcraft_time::parse_position(text, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start())
}
/// Whether the Edit window shows a track: unhidden, with every parent folder open.
pub fn track_shown(&self, track: &Track) -> bool {
if track.hidden {
return false;
}
let mut folder = track.folder;
for _ in 0..64 {
let Some(parent) = folder.and_then(|id| self.track(id)) else { return true };
if !parent.folder_open {
return false;
}
folder = parent.folder;
}
false
}
/// Collects unique visible active-playlist clip boundaries and memory-location boundaries.
fn clip_marker_grid_points(&self, keep: impl Fn(Samples) -> bool, excluded: &[ClipId], include_zero: bool) -> Vec<Samples> {
let mut points = Vec::new();
let excluded: std::collections::HashSet<ClipId> = excluded.iter().copied().collect();
if include_zero && keep(0) {
points.push(0);
}
for clip in
self.tracks.iter().filter(|track| self.track_shown(track)).flat_map(|track| track.clips()).filter(|clip| !excluded.contains(&clip.id))
{
points.extend([clip.start, clip.end()].into_iter().filter(|point| keep(*point)));
}
for marker in &self.markers {
if keep(marker.start) {
points.push(marker.start);
}
if marker.end != marker.start && keep(marker.end) {
points.push(marker.end);
}
}
points.sort_unstable();
points.dedup();
points
}
/// Sample-domain Timecode reference; Feet+Frames applies its origin in frames separately.
fn grid_origin(&self) -> Samples {
match self.edit.grid.scale() {
GridScale::Timecode => self.timecode_start,
_ => 0,
}
}
/// Grid lines for the current Grid value, including clip and marker boundaries.
pub fn grid_lines(&self, start: Samples, end: Samples, max: usize) -> Vec<Samples> {
if !self.edit.grid_clips_markers {
if let GridValue::FeetFrames(value) = self.edit.grid {
return value.lines(start, end, self.sample_rate, self.frame_rate, self.feet_frames_start(), max);
}
let origin = self.grid_origin();
return self
.edit
.grid
.lines(start.saturating_add(origin), end.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate, max)
.into_iter()
.map(|point| point.saturating_sub(origin))
.collect();
}
let points = self.clip_marker_grid_points(|point| point >= start && point < end, &[], true);
if points.len() <= max {
return points;
}
match max {
0 => Vec::new(),
1 => points.first().copied().into_iter().collect(),
_ => {
let span = i128::from(end).saturating_sub(i128::from(start)).max(1);
let mut bucket = None;
points
.into_iter()
.filter(|point| {
let next = i128::from(*point).saturating_sub(i128::from(start)).saturating_mul(max as i128) / span;
if bucket == Some(next) {
false
} else {
bucket = Some(next);
true
}
})
.collect()
}
}
}
/// The current Grid line at or before `position`.
pub fn grid_line_at_or_before(&self, position: Samples) -> Samples {
self.grid_line_at_or_before_excluding(position, &[])
}
/// Find the current Grid line at or before `position` without boundaries of `excluded` clips.
pub fn grid_line_at_or_before_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples {
if !self.edit.grid_clips_markers {
if let GridValue::FeetFrames(value) = self.edit.grid {
return value.line_at_or_before(position, self.sample_rate, self.frame_rate, self.feet_frames_start());
}
let origin = self.grid_origin();
return self
.edit
.grid
.line_at_or_before(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate)
.saturating_sub(origin);
}
self.clip_marker_grid_points(|_| true, excluded, true).into_iter().take_while(|point| *point <= position).last().unwrap_or(position)
}
/// Snap to the nearest line in the current Grid.
pub fn snap_to_grid(&self, position: Samples) -> Samples {
self.snap_to_grid_excluding(position, &[])
}
/// Snap to the nearest current Grid line without using boundaries of `excluded` clips.
pub fn snap_to_grid_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples {
if !self.edit.grid_clips_markers {
if let GridValue::FeetFrames(value) = self.edit.grid {
return value.snap(position, self.sample_rate, self.frame_rate, self.feet_frames_start());
}
let origin = self.grid_origin();
return self.edit.grid.snap(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate).saturating_sub(origin);
}
let mut points = self.clip_marker_grid_points(|_| true, excluded, false);
if !points.is_empty() {
points.push(0);
}
points.into_iter().min_by_key(|point| i128::from(*point).saturating_sub(i128::from(position)).abs()).unwrap_or(position)
}
pub fn next_track_color(&self) -> [u8; 3] {
let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count();
TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128])
@@ -578,13 +724,36 @@ impl Session {
}
/// Parse a session; decoded audio must be attached separately (`pool`).
///
/// The text is parsed straight into the document, never into a `serde_json::Value` tree
/// first (which takes many times the file's size). Only the version is read before that.
/// A field of the document given twice in one object is refused (`duplicate field`), as no
/// writer repeats one. Keys of maps (plugin params, edit values, Auto Rename schemes, MIDI
/// originals) and unknown keys keep the last value given, as before.
pub fn from_json(text: &str) -> Result<Session, SessionError> {
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?;
let ver = v.get("format_version").and_then(serde_json::Value::as_u64).ok_or_else(|| SessionError::Format("missing format_version".into()))?;
/// The version alone: every other field is skipped, not kept.
#[derive(serde::Deserialize)]
struct Head {
format_version: Option<serde_json::Number>,
}
// `Head` would also take a JSON array as its fields in order: only an object has a version.
let object = text.trim_start_matches([' ', '\t', '\n', '\r']).starts_with('{');
let version = match object.then(|| serde_json::from_str::<Head>(text)) {
Some(Ok(head)) => head.format_version,
// A version given twice is that error, not a missing version.
Some(Err(e)) if e.to_string().starts_with("duplicate field") => return Err(SessionError::Format(e.to_string())),
// Valid JSON of another shape (not an object, or a version that is not a number)
// has no version; anything else is not JSON.
_ => {
serde_json::from_str::<serde::de::IgnoredAny>(text).map_err(|e| SessionError::Format(e.to_string()))?;
None
}
};
let ver = version.and_then(|v| v.as_u64()).ok_or_else(|| SessionError::Format("missing format_version".into()))?;
if ver > u64::from(Self::FORMAT_VERSION) {
return Err(SessionError::Version(u32::try_from(ver).unwrap_or(u32::MAX)));
}
let mut s: Session = serde_json::from_value(v).map_err(|e| SessionError::Format(e.to_string()))?;
let mut s: Session = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?;
s.sanitize();
s.validate()?;
Ok(s)
@@ -762,6 +931,136 @@ mod tests {
assert_eq!(back, s);
}
/// Clips/Markers mode snaps and draws at clip and memory-location boundaries.
#[test]
fn clips_markers_grid_uses_visible_clip_edges_and_memory_locations() {
let mut s = Session::default();
let track = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let clip = Clip::midi(s.new_clip_id(), "Notes", 1_000, 2_000, soundcraft_midi::Sequence::default());
let clip_id = clip.id;
s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(clip);
let hidden = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let hidden_clip = Clip::midi(s.new_clip_id(), "Hidden", 3_200, 100, soundcraft_midi::Sequence::default());
if let Some(track) = s.track_mut(hidden) {
track.hidden = true;
track.playlist_mut().unwrap().clips.push(hidden_clip);
}
s.add_marker("Cue", MarkerKind::Marker, 2_500, 2_500);
s.add_marker("Selection", MarkerKind::Selection, 4_000, 4_500);
s.edit.grid_clips_markers = true;
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 2_500, 3_000, 4_000, 4_500]);
assert_eq!(s.grid_line_at_or_before(2_800), 2_500);
assert_eq!(s.snap_to_grid(2_800), 3_000);
assert_eq!(s.snap_to_grid(3_700), 4_000);
assert_eq!(s.grid_lines(2_400, 4_100, 2), [2_500, 4_000]);
assert_eq!(s.snap_to_grid(3_200), 3_000);
assert_eq!(s.snap_to_grid_excluding(2_800, &[clip_id]), 2_500);
let folder = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
s.track_mut(track).unwrap().folder = Some(folder);
s.track_mut(folder).unwrap().folder_open = false;
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 2_500, 4_000, 4_500]);
}
/// Open hidden folders keep their visible descendants eligible for lines and snapping.
#[test]
fn clips_markers_grid_keeps_visible_children_of_hidden_open_folders() {
let mut s = Session::default();
let outer = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
let inner = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
let child = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let clip = Clip::midi(s.new_clip_id(), "Visible", 1_000, 2_000, soundcraft_midi::Sequence::default());
s.track_mut(inner).unwrap().folder = Some(outer);
s.track_mut(inner).unwrap().hidden = true;
s.track_mut(child).unwrap().folder = Some(inner);
s.track_mut(child).unwrap().playlist_mut().unwrap().clips.push(clip);
s.edit.grid_clips_markers = true;
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 3_000]);
assert_eq!(s.snap_to_grid(2_800), 3_000);
assert_eq!(s.grid_line_at_or_before(2_800), 1_000);
s.track_mut(child).unwrap().hidden = true;
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
assert_eq!(s.snap_to_grid(2_800), 2_800);
s.track_mut(child).unwrap().hidden = false;
s.track_mut(inner).unwrap().folder_open = false;
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
s.track_mut(inner).unwrap().folder_open = true;
s.track_mut(outer).unwrap().folder_open = false;
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
}
/// With no real Clips/Markers boundaries, snapping leaves the position alone while drawing
/// retains the session-start line.
#[test]
fn empty_clips_markers_grid_does_not_snap_everything_to_zero() {
let mut s = Session::default();
s.edit.grid_clips_markers = true;
assert_eq!(s.grid_lines(0, 1_000, 10), [0]);
assert_eq!(s.snap_to_grid(678), 678);
}
/// Frame-based grids share the same references as their displayed counters.
#[test]
fn frame_grids_apply_session_counter_origins() {
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps30, timecode_start: 1_600, ..Session::default() };
s.edit.grid = GridValue::Timecode(soundcraft_time::TimecodeValue::Second);
assert_eq!(s.grid_line_at_or_before(47_000), 46_400);
assert_eq!(s.snap_to_grid(46_400), 46_400);
s.edit.grid = GridValue::FeetFrames(soundcraft_time::FeetFramesValue::Foot);
s.edit.values.insert(FEET_FRAMES_START.into(), 1.0);
assert_eq!(s.grid_line_at_or_before(25_000), 24_000);
assert_eq!(s.snap_to_grid(24_000), 24_000);
}
/// Every Feet+Frames increment shares the parser's single rounding at fractional frame rates.
#[test]
fn feet_frames_grid_matches_parsed_positions_with_signed_frame_origins() {
use soundcraft_time::FeetFramesValue;
for rate in [FrameRate::Fps23_976, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: rate, ..Session::default() };
for origin in [-17, -1, 1, 17] {
s.edit.values.insert(FEET_FRAMES_START.into(), origin as f64);
for value in FeetFramesValue::ALL {
s.edit.grid = GridValue::FeetFrames(value);
for feet in [-64, -2, 0, 1, 2, 4, 7, 12, 128] {
let at = s.parse_position(&format!("{feet}+00"), TimeFormat::FeetFrames).unwrap();
assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?} {value:?} origin {origin}, foot {feet}");
assert_eq!(s.snap_to_grid(at), at);
assert_eq!(s.grid_lines(at - 1, at + 2, 4), [at]);
}
}
}
}
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps29_97, ..Session::default() };
s.edit.values.insert(FEET_FRAMES_START.into(), 1.0);
s.edit.grid = GridValue::FeetFrames(FeetFramesValue::Foot);
assert_eq!(s.snap_to_grid(49_649), 49_650);
assert_eq!(s.format_position(49_650, TimeFormat::FeetFrames), "2+00");
}
/// Drop-frame minute boundaries keep their counter alignment with signed session origins.
#[test]
fn drop_frame_grids_apply_session_origins_at_minute_boundaries() {
for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1_800), (FrameRate::Fps59_94Drop, 3_600)] {
for origin_frame in [-1, 1] {
let sr = SampleRate::HZ_48000;
let origin = rate.samples_at_frame(origin_frame, sr);
let mut s = Session { sample_rate: sr, frame_rate: rate, timecode_start: origin, ..Session::default() };
s.edit.grid = GridValue::Timecode(soundcraft_time::TimecodeValue::Second);
for boundary in [-minute_frame, minute_frame] {
let at = rate.samples_at_frame(boundary, sr) - origin;
assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?}, origin {origin_frame}");
assert_eq!(s.snap_to_grid(at + 1), at, "{rate:?}, origin {origin_frame}");
assert_eq!(s.grid_lines(at - 1, at + 2, 10), [at]);
}
}
}
}
#[test]
fn session_with_legacy_fit_height_loads() {
let mut s = Session::default();
@@ -775,6 +1074,31 @@ mod tests {
assert_eq!(back.track(t).map(|tr| tr.height), Some(crate::TrackHeight::Medium));
}
/// Session JSON omits live flags and ignores their values in legacy documents.
#[test]
fn live_input_states_are_never_saved_or_restored() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let track = s.track_mut(t).unwrap();
track.mixer.mute = true;
track.mixer.record_arm = true;
track.mixer.input_monitor = true;
let mut value = serde_json::to_value(&s).unwrap();
let mixer = value["tracks"][0]["mixer"].as_object_mut().unwrap();
assert!(!mixer.contains_key("record_arm"));
assert!(!mixer.contains_key("input_monitor"));
// Files written by older versions may still contain these keys; ignore them on input.
mixer.insert("record_arm".into(), serde_json::Value::Bool(true));
mixer.insert("input_monitor".into(), serde_json::Value::Bool(true));
let loaded = Session::from_json(&value.to_string()).unwrap();
let track = loaded.track(t).unwrap();
assert!(track.mixer.mute, "persistent mixer state still loads");
assert!(!track.mixer.record_arm);
assert!(!track.mixer.input_monitor);
}
#[test]
fn video_clip_round_trips_and_old_sessions_load() {
let mut s = Session::default();
@@ -864,6 +1188,53 @@ mod tests {
assert!(matches!(Session::from_json(&v.to_string()), Err(SessionError::Version(999))));
}
#[test]
fn the_version_is_checked_as_before() {
// #892: the version is read without building the whole document as a tree; the errors
// stay what they were.
let format = |t: &str| match Session::from_json(t) {
Err(SessionError::Format(m)) => m,
other => panic!("{t}: {other:?}"),
};
for t in [
"{}",
"[]",
"[5]",
" \n[1, {}]",
"\"x\"",
"5",
"null",
r#"{"format_version":"1"}"#,
r#"{"format_version":-1}"#,
r#"{"format_version":1.5}"#,
r#"{"format_version":null}"#,
r#"{"format_version":[0,0,0]}"#,
r#"{"name":"x","other":{"format_version":1}}"#,
] {
assert_eq!(format(t), "missing format_version", "{t}");
}
for t in ["not json", r#"{"format_version":1"#, r#"{"format_version":1} x"#, r#"{"format_version":"1", ]"#] {
let want = serde_json::from_str::<serde_json::Value>(t).unwrap_err().to_string();
assert_eq!(format(t), want, "{t}");
}
let mut v = serde_json::to_value(Session::default()).unwrap();
for (ver, want) in [(serde_json::json!(2), 2), (serde_json::json!(1u64 << 40), u32::MAX)] {
v["format_version"] = ver;
assert_eq!(Session::from_json(&v.to_string()), Err(SessionError::Version(want)));
}
// A field given twice is refused by name, the version too.
let twice = |key: &str| format(&format!(r#"{{"format_version":1,"{key}":"a","{key}":"b"}}"#));
assert!(twice("name").starts_with("duplicate field `name` at line 1 column "), "{}", twice("name"));
assert!(twice("format_version").starts_with("duplicate field `format_version` at line 1 column "), "{}", twice("format_version"));
// A newer version is refused even when the rest would not parse.
assert_eq!(Session::from_json(r#"{"format_version":7,"tracks":5}"#), Err(SessionError::Version(7)));
// An older or the current version loads as it did.
for ver in [0, 1] {
v["format_version"] = serde_json::json!(ver);
assert_eq!(Session::from_json(&v.to_string()).map(|s| s.format_version), Ok(ver));
}
}
#[test]
fn duplicate_ids_are_rejected_on_load() {
let mut s = Session::default();
+86 -37
View File
@@ -83,8 +83,7 @@ impl TrackKind {
}
/// Track heights (View › Track Height presets).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
#[serde(from = "TrackHeightRepr")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize)]
pub enum TrackHeight {
/// Any height from Micro to Extreme, in points.
Custom(u16),
@@ -100,49 +99,69 @@ pub enum TrackHeight {
Fit(u16),
}
/// What a saved track height may look like: the current form, or the bare `"Fit"` string older sessions used.
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum TrackHeightRepr {
Current(TrackHeightCurrent),
LegacyFit(LegacyFit),
/// A saved track height: `"Micro"` … `"Extreme"` (or `{"Micro": null}` …), `{"Custom": points}`,
/// `{"Fit": points}`, or the bare `"Fit"` older sessions used. Read as it comes, never buffered (as an
/// untagged enum would), so anything else in a session file is refused at its first token.
impl<'de> serde::Deserialize<'de> for TrackHeight {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_any(HeightVisitor)
}
}
#[derive(serde::Deserialize)]
enum LegacyFit {
Fit,
const HEIGHT_VARIANTS: &[&str] = &["Custom", "Micro", "Mini", "Small", "Medium", "Large", "Jumbo", "Extreme", "Fit"];
struct HeightVisitor;
impl HeightVisitor {
/// The height a variant without a size names.
fn preset(name: &str) -> Option<TrackHeight> {
Some(match name {
"Micro" => TrackHeight::Micro,
"Mini" => TrackHeight::Mini,
"Small" => TrackHeight::Small,
"Medium" => TrackHeight::Medium,
"Large" => TrackHeight::Large,
"Jumbo" => TrackHeight::Jumbo,
"Extreme" => TrackHeight::Extreme,
_ => return None,
})
}
}
/// Mirror of `TrackHeight` that derives the current serialized form.
#[derive(serde::Deserialize)]
enum TrackHeightCurrent {
Custom(u16),
Micro,
Mini,
Small,
Medium,
Large,
Jumbo,
Extreme,
Fit(u16),
}
impl<'de> serde::de::Visitor<'de> for HeightVisitor {
type Value = TrackHeight;
impl From<TrackHeightRepr> for TrackHeight {
fn from(repr: TrackHeightRepr) -> Self {
match repr {
TrackHeightRepr::Current(TrackHeightCurrent::Custom(p)) => TrackHeight::Custom(p),
TrackHeightRepr::Current(TrackHeightCurrent::Micro) => TrackHeight::Micro,
TrackHeightRepr::Current(TrackHeightCurrent::Mini) => TrackHeight::Mini,
TrackHeightRepr::Current(TrackHeightCurrent::Small) => TrackHeight::Small,
TrackHeightRepr::Current(TrackHeightCurrent::Medium) => TrackHeight::Medium,
TrackHeightRepr::Current(TrackHeightCurrent::Large) => TrackHeight::Large,
TrackHeightRepr::Current(TrackHeightCurrent::Jumbo) => TrackHeight::Jumbo,
TrackHeightRepr::Current(TrackHeightCurrent::Extreme) => TrackHeight::Extreme,
TrackHeightRepr::Current(TrackHeightCurrent::Fit(p)) => TrackHeight::Fit(p),
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a track height")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<TrackHeight, E> {
match v {
// Older sessions stored Fit with no size, drawn 88 points tall: the default Medium height.
TrackHeightRepr::LegacyFit(LegacyFit::Fit) => TrackHeight::Medium,
"Fit" => Ok(TrackHeight::Medium),
"Custom" => Err(E::invalid_type(serde::de::Unexpected::UnitVariant, &"a height in points")),
other => Self::preset(other).ok_or_else(|| E::unknown_variant(other, HEIGHT_VARIANTS)),
}
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<TrackHeight, A::Error> {
use serde::de::Error as _;
let Some(key) = map.next_key::<String>()? else { return Err(A::Error::invalid_length(0, &"one variant")) };
let h = match key.as_str() {
"Custom" => TrackHeight::Custom(map.next_value()?),
// `{"Fit": null}` is the old size-less Fit too, as above.
"Fit" => map.next_value::<Option<u16>>()?.map_or(TrackHeight::Medium, TrackHeight::Fit),
other => {
let h = Self::preset(other).ok_or_else(|| A::Error::unknown_variant(other, HEIGHT_VARIANTS))?;
map.next_value::<()>()?;
h
}
};
if map.next_key::<serde::de::IgnoredAny>()?.is_some() {
return Err(A::Error::invalid_length(2, &"one variant"));
}
Ok(h)
}
}
impl TrackHeight {
@@ -383,6 +402,36 @@ mod tests {
assert_eq!(serde_json::from_str::<TrackHeight>("{\"Custom\":50}").unwrap(), TrackHeight::Custom(50));
}
#[test]
fn every_saved_height_form_loads_and_nothing_else() {
for h in TrackHeight::ALL.into_iter().chain([TrackHeight::Custom(50), TrackHeight::Fit(120)]) {
assert_eq!(serde_json::from_str::<TrackHeight>(&serde_json::to_string(&h).unwrap()).unwrap(), h);
assert_eq!(serde_json::from_value::<TrackHeight>(serde_json::to_value(h).unwrap()).unwrap(), h);
}
for h in TrackHeight::ALL {
let null = format!("{{{}:null}}", serde_json::to_string(&h).unwrap());
assert_eq!(serde_json::from_str::<TrackHeight>(&null).unwrap(), h, "{null}");
}
assert_eq!(serde_json::from_str::<TrackHeight>(r#"{"Fit":null}"#).unwrap(), TrackHeight::Medium);
assert!(serde_json::from_str::<TrackHeight>(r#"{"Custom":null}"#).is_err());
for bad in
[r#""Custom""#, r#""Huge""#, "7", "[]", "null", "{}", r#"{"Fit":"x"}"#, r#"{"Fit":70000}"#, r#"{"Large":1}"#, r#"{"Fit":1,"Fit":2}"#]
{
assert!(serde_json::from_str::<TrackHeight>(bad).is_err(), "{bad}");
}
}
#[test]
fn a_huge_height_is_refused_at_its_first_token() {
// #892: the height was read through an untagged enum, which buffers the whole value
// first; a session could hold a huge array there. It is refused where it starts.
for (open, rest) in [("[", "0,".repeat(100_000) + "0]"), (r#"{"Fit":["#, "0,".repeat(100_000) + "0]}")] {
let text = format!("{open}{rest}");
let err = serde_json::from_str::<TrackHeight>(&text).unwrap_err();
assert!(err.is_data() && err.line() == 1 && err.column() <= open.len() + 1, "{open}: {err}");
}
}
#[test]
fn new_track_defaults() {
let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]);
+499 -20
View File
@@ -1,11 +1,12 @@
//! Grid and Nudge values: musical or absolute step sizes, grid lines and snapping.
use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, to_samples};
use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, Timecode, to_samples};
/// Note values for Bars|Beats grids.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum NoteValue {
Bar,
Whole,
Half,
Quarter,
Eighth,
@@ -15,8 +16,10 @@ pub enum NoteValue {
}
impl NoteValue {
pub const ALL: [NoteValue; 7] = [
/// Every musical increment in menu order, from largest to smallest.
pub const ALL: [NoteValue; 8] = [
NoteValue::Bar,
NoteValue::Whole,
NoteValue::Half,
NoteValue::Quarter,
NoteValue::Eighth,
@@ -24,9 +27,11 @@ impl NoteValue {
NoteValue::ThirtySecond,
NoteValue::SixtyFourth,
];
/// Returns the nominal tick length; bars are adjusted for the active meter when used.
pub fn ticks(self) -> i64 {
match self {
NoteValue::Bar => TICKS_PER_QUARTER * 4,
NoteValue::Whole => TICKS_PER_QUARTER * 4,
NoteValue::Half => TICKS_PER_QUARTER * 2,
NoteValue::Quarter => TICKS_PER_QUARTER,
NoteValue::Eighth => TICKS_PER_QUARTER / 2,
@@ -35,9 +40,11 @@ impl NoteValue {
NoteValue::SixtyFourth => TICKS_PER_QUARTER / 16,
}
}
/// Returns the menu label for this musical increment.
pub fn label(self) -> &'static str {
match self {
NoteValue::Bar => "1 bar",
NoteValue::Whole => "Whole note",
NoteValue::Half => "1/2 note",
NoteValue::Quarter => "1/4 note",
NoteValue::Eighth => "1/8 note",
@@ -48,26 +55,257 @@ impl NoteValue {
}
}
/// The time scale used by a Grid or Nudge value.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum GridScale {
BarsBeats,
MinSecs,
Timecode,
FeetFrames,
Samples,
ClipsMarkers,
}
impl GridScale {
/// Numeric time scales shared by Grid and Nudge.
pub const TIME: [GridScale; 5] = [GridScale::BarsBeats, GridScale::MinSecs, GridScale::Timecode, GridScale::FeetFrames, GridScale::Samples];
/// Returns the user-facing scale label.
pub fn label(self) -> &'static str {
match self {
GridScale::BarsBeats => "Bars|Beats",
GridScale::MinSecs => "Min:Secs",
GridScale::Timecode => "Timecode",
GridScale::FeetFrames => "Feet+Frames",
GridScale::Samples => "Samples",
GridScale::ClipsMarkers => "Clips/Markers",
}
}
/// Returns the stable command and session identifier.
pub fn id(self) -> &'static str {
match self {
GridScale::BarsBeats => "bars_beats",
GridScale::MinSecs => "min_secs",
GridScale::Timecode => "timecode",
GridScale::FeetFrames => "feet_frames",
GridScale::Samples => "samples",
GridScale::ClipsMarkers => "clips_markers",
}
}
/// Parses a stable identifier or user-facing label.
pub fn from_id(id: &str) -> Option<GridScale> {
Self::TIME.into_iter().chain([GridScale::ClipsMarkers]).find(|scale| scale.id() == id || scale.label().eq_ignore_ascii_case(id))
}
}
/// Timecode Grid/Nudge increments, ordered independently of the session frame rate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum TimecodeValue {
Second,
/// Five physical frames, independent of skipped drop-frame labels.
FiveFrames,
Frame,
HalfFrame,
QuarterFrame,
SubFrame,
}
impl TimecodeValue {
/// Every Timecode increment in menu order, from largest to smallest.
pub const ALL: [TimecodeValue; 6] = [
TimecodeValue::Second,
TimecodeValue::FiveFrames,
TimecodeValue::Frame,
TimecodeValue::HalfFrame,
TimecodeValue::QuarterFrame,
TimecodeValue::SubFrame,
];
/// Returns the user-facing increment label.
pub fn label(self) -> &'static str {
match self {
TimecodeValue::Second => "1 second",
TimecodeValue::FiveFrames => "5 frames",
TimecodeValue::Frame => "1 frame",
TimecodeValue::HalfFrame => "1/2 frame",
TimecodeValue::QuarterFrame => "1/4 frame",
TimecodeValue::SubFrame => "1 subframe",
}
}
/// Returns the stable command and session identifier.
pub fn id(self) -> &'static str {
match self {
TimecodeValue::Second => "second",
TimecodeValue::FiveFrames => "5_frames",
TimecodeValue::Frame => "frame",
TimecodeValue::HalfFrame => "half_frame",
TimecodeValue::QuarterFrame => "quarter_frame",
TimecodeValue::SubFrame => "subframe",
}
}
/// Parses a stable identifier or user-facing label.
pub fn from_id(id: &str) -> Option<TimecodeValue> {
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
}
/// Returns the increment length in frames at the session rate.
fn frames(self, rate: FrameRate) -> f64 {
match self {
TimecodeValue::Second => rate.nominal() as f64,
TimecodeValue::FiveFrames => 5.0,
TimecodeValue::Frame => 1.0,
TimecodeValue::HalfFrame => 0.5,
TimecodeValue::QuarterFrame => 0.25,
TimecodeValue::SubFrame => 0.01,
}
}
}
/// Feet+Frames Grid/Nudge increments in physical frames, independent of drop-frame label skips.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum FeetFramesValue {
Foot,
Frame,
QuarterFrame,
SubFrame,
}
impl FeetFramesValue {
/// Every Feet+Frames increment in menu order, from largest to smallest.
pub const ALL: [FeetFramesValue; 4] = [FeetFramesValue::Foot, FeetFramesValue::Frame, FeetFramesValue::QuarterFrame, FeetFramesValue::SubFrame];
/// Returns the user-facing increment label.
pub fn label(self) -> &'static str {
match self {
FeetFramesValue::Foot => "1 foot",
FeetFramesValue::Frame => "1 frame",
FeetFramesValue::QuarterFrame => "1/4 frame",
FeetFramesValue::SubFrame => "1 subframe",
}
}
/// Returns the stable command and session identifier.
pub fn id(self) -> &'static str {
match self {
FeetFramesValue::Foot => "foot",
FeetFramesValue::Frame => "frame",
FeetFramesValue::QuarterFrame => "quarter_frame",
FeetFramesValue::SubFrame => "subframe",
}
}
/// Parses a stable identifier or user-facing label.
pub fn from_id(id: &str) -> Option<FeetFramesValue> {
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
}
/// Returns the increment length in frames.
fn frames(self) -> f64 {
let (num, den) = self.frame_ratio();
num as f64 / den as f64
}
/// Exact numerator and denominator of the increment in physical frames.
fn frame_ratio(self) -> (i128, i128) {
match self {
Self::Foot => (16, 1),
Self::Frame => (1, 1),
Self::QuarterFrame => (1, 4),
Self::SubFrame => (1, 100),
}
}
/// Apply the counter origin in frames before rounding once to the nearest sample.
fn sample_line(self, index: i128, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
let (num, den) = self.frame_ratio();
let (rate_num, rate_den) = rate.rational();
let frames = index * num - i128::from(origin) * den;
let samples = frames * i128::from(sr.hz().max(1)) * i128::from(rate_den);
let divisor = den * i128::from(rate_num);
let magnitude = (samples.abs() + divisor / 2) / divisor;
let rounded = if samples < 0 { -magnitude } else { magnitude };
i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX })
}
/// Find a line index using exact frame arithmetic, allowing for whole-sample rounding.
fn line_index(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> i128 {
let (num, den) = self.frame_ratio();
let (rate_num, rate_den) = rate.rational();
let scale = i128::from(sr.hz().max(1)) * i128::from(rate_den);
let frames = i128::from(position) * i128::from(rate_num) + i128::from(origin) * scale;
let mut index = (frames * den).div_euclid(scale * num);
if self.sample_line(index + 1, sr, rate, origin) <= position {
index += 1;
}
index
}
/// The Feet+Frames line at or before a sample, relative to the counter's frame origin.
pub fn line_at_or_before(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
self.sample_line(self.line_index(position, sr, rate, origin), sr, rate, origin)
}
/// The next distinct sample line; subframe increments may share a sample at low sample rates.
fn next_line(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
self.sample_line(self.line_index(position, sr, rate, origin) + 1, sr, rate, origin).max(position.saturating_add(1))
}
/// Snap against frame-origin lines, choosing the earlier line when distances are equal.
pub fn snap(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
let lo = self.line_at_or_before(position, sr, rate, origin);
let hi = self.next_line(lo, sr, rate, origin);
if i128::from(position) - i128::from(lo) <= i128::from(hi) - i128::from(position) { lo } else { hi }
}
/// Distinct Feet+Frames sample lines within `[start, end)`, capped at `max`.
pub fn lines(self, start: Samples, end: Samples, sr: SampleRate, rate: FrameRate, origin: i64, max: usize) -> Vec<Samples> {
let mut out = Vec::new();
let mut line = self.line_at_or_before(start, sr, rate, origin);
while line < end && out.len() < max {
if line >= start {
out.push(line);
}
let next = self.next_line(line, sr, rate, origin);
if next <= line {
break;
}
line = next;
}
out
}
}
/// A grid or nudge step.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum GridValue {
/// Musical: note value, optionally dotted or triplet.
Note { value: NoteValue, dotted: bool, triplet: bool },
/// Absolute seconds (Min:Secs grids: 0.001 … 10 s).
/// Absolute seconds (Min:Secs grids: 0.001 … 1 s).
Seconds(f64),
/// Frames at the session timecode rate.
/// Kept for sessions and commands written before named timecode increments were added.
Frames(i64),
/// A named Timecode increment, including fractional frames and one second.
Timecode(TimecodeValue),
/// A Feet+Frames increment (one foot is 16 frames).
FeetFrames(FeetFramesValue),
/// Raw samples.
Samples(i64),
}
impl Default for GridValue {
/// Starts on a straight sixteenth-note Bars|Beats grid.
fn default() -> Self {
GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }
}
}
impl GridValue {
/// Returns the user-facing label for this increment.
pub fn label(&self) -> String {
match self {
GridValue::Note { value, dotted, triplet } => {
@@ -80,15 +318,47 @@ impl GridValue {
}
s
}
GridValue::Seconds(s) => format!("{s:.3} sec"),
GridValue::Seconds(s) => {
if *s >= 1.0 {
format!("{s} second{}", if *s == 1.0 { "" } else { "s" })
} else {
format!("{} ms", (s * 1000.0).round() as i64)
}
}
GridValue::Frames(f) => format!("{f} frame{}", if *f == 1 { "" } else { "s" }),
GridValue::Samples(n) => format!("{n} samples"),
GridValue::Timecode(value) => value.label().to_string(),
GridValue::FeetFrames(value) => value.label().to_string(),
GridValue::Samples(n) => format!("{n} sample{}", if *n == 1 { "" } else { "s" }),
}
}
/// The next larger or smaller value of the same kind, or `self` at either end. Note values keep their
/// dot or triplet; seconds, frames and samples step by tens.
/// Returns the time scale to which this increment belongs.
pub fn scale(self) -> GridScale {
match self {
GridValue::Note { .. } => GridScale::BarsBeats,
GridValue::Seconds(_) => GridScale::MinSecs,
GridValue::Frames(_) | GridValue::Timecode(_) => GridScale::Timecode,
GridValue::FeetFrames(_) => GridScale::FeetFrames,
GridValue::Samples(_) => GridScale::Samples,
}
}
/// Returns the default increment for a newly selected time scale.
pub fn for_scale(scale: GridScale) -> GridValue {
match scale {
GridScale::BarsBeats => GridValue::default(),
GridScale::MinSecs => GridValue::Seconds(1.0),
GridScale::Timecode => GridValue::Timecode(TimecodeValue::Frame),
GridScale::FeetFrames => GridValue::FeetFrames(FeetFramesValue::Frame),
GridScale::Samples => GridValue::Samples(100),
GridScale::ClipsMarkers => GridValue::default(),
}
}
/// The next larger or smaller menu value of the same kind, or `self` at either end. Note values keep
/// their dot or triplet modifier.
pub fn stepped(self, larger: bool) -> GridValue {
/// Finds the next value in a numeric increment ladder without wrapping.
fn next<T: PartialOrd + Copy>(ladder: &[T], v: T, larger: bool) -> Option<T> {
if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) }
}
@@ -98,9 +368,11 @@ impl GridValue {
let j = if larger { i.checked_sub(1) } else { i.checked_add(1) };
j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet })
}
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 1.0, 10.0], s, larger).map_or(self, GridValue::Seconds),
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 0.5, 1.0], s, larger).map_or(self, GridValue::Seconds),
GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames),
GridValue::Samples(n) => next(&[1, 10, 100, 1_000, 10_000, 100_000], n, larger).map_or(self, GridValue::Samples),
GridValue::Timecode(value) => step_enum(&TimecodeValue::ALL, value, larger).map_or(self, GridValue::Timecode),
GridValue::FeetFrames(value) => step_enum(&FeetFramesValue::ALL, value, larger).map_or(self, GridValue::FeetFrames),
GridValue::Samples(n) => next(&[1, 2, 10, 100, 1_000, 10_000], n, larger).map_or(self, GridValue::Samples),
}
}
@@ -126,12 +398,17 @@ impl GridValue {
}
GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1),
GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1),
GridValue::Timecode(value) => to_samples(rate.samples_per_frame(sr) * value.frames(rate)).max(1),
GridValue::FeetFrames(value) => to_samples(rate.samples_per_frame(sr) * value.frames()).max(1),
GridValue::Samples(n) => n.max(1),
}
}
/// Nearest grid line to `pos`.
pub fn snap(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
if let Self::FeetFrames(value) = *self {
return value.snap(pos, sr, rate, 0);
}
let lo = self.line_at_or_before(pos, sr, map, rate);
let hi = self.next_line(lo, sr, map, rate);
if (pos - lo) <= (hi - pos) { lo } else { hi }
@@ -140,6 +417,7 @@ impl GridValue {
/// The grid line at or before `pos`.
pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
match *self {
Self::FeetFrames(value) => value.line_at_or_before(pos, sr, rate, 0),
GridValue::Note { value, dotted, triplet } => {
// Float ticks can land a hair either side of a line's own tick.
let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64;
@@ -148,12 +426,14 @@ impl GridValue {
} else if map.tick_to_samples(t.saturating_add(1), sr) <= pos {
t = t.saturating_add(1);
}
// Grid lines restart at each bar line so odd meters stay aligned.
// Sub-bar values restart at each bar so odd meters stay aligned. A whole note is
// always four quarter notes and therefore keeps a continuous tick origin.
let bb = map.bar_beat_at_tick(t);
let bar_tick = map.bar_start_tick(bb.bar);
let step = Self::note_ticks(value, dotted, triplet, map, bar_tick);
let k = (t - bar_tick).div_euclid(step);
map.tick_to_samples(bar_tick + k * step, sr)
let origin = if value == NoteValue::Whole { 0 } else { bar_tick };
let step = Self::note_ticks(value, dotted, triplet, map, origin);
let k = (t - origin).div_euclid(step);
map.tick_to_samples(origin + k * step, sr)
}
_ => self.abs_line(self.abs_index_at_or_before(pos, sr, rate), sr, rate),
}
@@ -162,21 +442,60 @@ impl GridValue {
/// Position of absolute grid line `k` (Seconds, Frames, Samples), at an exact multiple of the step
/// rounded once, so rounding never accumulates from line to line.
fn abs_line(&self, k: i64, sr: SampleRate, rate: FrameRate) -> Samples {
if let Self::FeetFrames(value) = *self {
return value.sample_line(i128::from(k), sr, rate, 0);
}
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
return rate.samples_at_frame(Self::drop_second_frame(k, rate), sr);
}
if let Some(frames) = self.frame_step(rate) {
let whole = frames.round();
if frames == whole {
return rate.samples_at_frame(k.saturating_mul(whole as i64), sr);
}
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
return to_samples(k as f64 * step);
}
match *self {
GridValue::Seconds(s) => to_samples(k as f64 * Self::seconds_step(s, sr)),
GridValue::Frames(f) => rate.samples_at_frame(k.saturating_mul(f.clamp(1, 1_000_000)), sr),
GridValue::Samples(n) => k.saturating_mul(n.max(1)),
GridValue::Note { .. } => 0,
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => 0,
}
}
/// Index of the absolute grid line at or before `pos`.
fn abs_index_at_or_before(&self, pos: Samples, sr: SampleRate, rate: FrameRate) -> i64 {
let mut k = match *self {
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
GridValue::Frames(f) => rate.frames_at(pos, sr).div_euclid(f.clamp(1, 1_000_000)),
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
GridValue::Note { .. } => return 0,
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
let tc = Timecode::from_frames(rate.frames_at(pos, sr), rate);
let magnitude = tc.hours.saturating_mul(3600).saturating_add(tc.minutes.saturating_mul(60)).saturating_add(tc.seconds);
let mut k = if tc.negative { magnitude.saturating_neg() } else { magnitude };
while self.abs_line(k, sr, rate) > pos {
let previous = k.saturating_sub(1);
if previous == k {
break;
}
k = previous;
}
while self.abs_line(k.saturating_add(1), sr, rate) <= pos {
let next = k.saturating_add(1);
if next == k {
break;
}
k = next;
}
return k;
}
let mut k = if let Some(frames) = self.frame_step(rate) {
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
(pos as f64 / step).floor() as i64
} else {
match *self {
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
GridValue::Note { .. } => return 0,
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => return 0,
}
};
// Lines are rounded to whole samples, so the floored index can be one off either way.
if self.abs_line(k, sr, rate) > pos {
@@ -192,8 +511,36 @@ impl GridValue {
(s.clamp(0.000_01, 3600.0) * sr.as_f64()).max(1.0)
}
/// Maps a signed whole-second timecode label to its physical frame at a drop-frame rate.
fn drop_second_frame(index: i64, rate: FrameRate) -> i64 {
let seconds = index.unsigned_abs();
let total_minutes = seconds / 60;
let frames = if seconds.is_multiple_of(60) && !total_minutes.is_multiple_of(10) { if rate.nominal() >= 60 { 4 } else { 2 } } else { 0 };
Timecode {
negative: index < 0,
hours: i64::try_from(seconds / 3600).unwrap_or(i64::MAX),
minutes: i64::try_from((seconds / 60) % 60).unwrap_or(0),
seconds: i64::try_from(seconds % 60).unwrap_or(0),
frames,
}
.to_frames(rate)
}
/// A frame-based increment as a possibly fractional number of frames.
fn frame_step(&self, rate: FrameRate) -> Option<f64> {
match *self {
GridValue::Frames(frames) => Some(frames.clamp(1, 1_000_000) as f64),
GridValue::Timecode(value) => Some(value.frames(rate)),
GridValue::FeetFrames(value) => Some(value.frames()),
_ => None,
}
}
/// The first grid line strictly after `line`.
pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
if let Self::FeetFrames(value) = *self {
return value.next_line(line, sr, rate, 0);
}
if !matches!(self, GridValue::Note { .. }) {
let next = self.abs_line(self.abs_index_at_or_before(line, sr, rate).saturating_add(1), sr, rate);
return if next > line { next } else { line.saturating_add(1) };
@@ -205,6 +552,9 @@ impl GridValue {
/// All grid lines within `[start, end)`, capped at `max` lines.
pub fn lines(&self, start: Samples, end: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate, max: usize) -> Vec<Samples> {
if let Self::FeetFrames(value) = *self {
return value.lines(start, end, sr, rate, 0, max);
}
let mut out = Vec::new();
let mut p = self.line_at_or_before(start, sr, map, rate);
while p < end && out.len() < max {
@@ -221,10 +571,43 @@ impl GridValue {
}
}
/// Moves one position through an ordered increment menu without wrapping.
fn step_enum<T: Copy + PartialEq>(values: &[T], value: T, larger: bool) -> Option<T> {
let index = values.iter().position(|candidate| *candidate == value)?;
let next = if larger { index.checked_sub(1) } else { index.checked_add(1) }?;
values.get(next).copied()
}
#[cfg(test)]
mod tests {
use super::*;
/// Frame origins are subtracted before rounding whole frames, quarter frames and subframes.
#[test]
fn feet_frames_origins_round_only_after_applying_the_frame_offset() {
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97);
for at in [49_650, 100_901, 177_778] {
assert_eq!(FeetFramesValue::Foot.line_at_or_before(at, sr, rate, 1), at);
assert_eq!(FeetFramesValue::Foot.snap(at - 1, sr, rate, 1), at);
}
assert_eq!(FeetFramesValue::QuarterFrame.snap(50_049, sr, rate, 1), 50_050);
assert_eq!(FeetFramesValue::SubFrame.lines(49_665, 49_667, sr, rate, 1, 4), [49_666]);
}
/// Dense subframes retain distinct sample lines and remain bounded with extreme signed origins.
#[test]
fn feet_frames_subframes_handle_dense_steps_and_extreme_origins() {
let sr = SampleRate::new(8_000).unwrap();
let rate = FrameRate::Fps120;
for origin in [i64::MIN, -1, 0, 1, i64::MAX] {
assert_eq!(FeetFramesValue::SubFrame.lines(-3, 4, sr, rate, origin, 20), [-3, -2, -1, 0, 1, 2, 3]);
for at in [i64::MIN, -1, 0, 1, i64::MAX] {
assert_eq!(FeetFramesValue::SubFrame.line_at_or_before(at, sr, rate, origin), at);
assert_eq!(FeetFramesValue::SubFrame.snap(at, sr, rate, origin), at);
}
}
}
#[test]
fn grid_lines_are_their_own_line_at_odd_tempos() {
let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30);
@@ -238,6 +621,29 @@ mod tests {
}
}
/// Musical boundaries stay exact across a tempo change while absolute scales remain
/// independent of the tempo map.
#[test]
fn grids_stay_aligned_across_a_tempo_change() {
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97Drop);
let mut map = TempoMap::default();
map.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap();
let musical = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: false };
for tick in [0, TICKS_PER_QUARTER * 3, TICKS_PER_QUARTER * 4, TICKS_PER_QUARTER * 5] {
let line = map.tick_to_samples(tick, sr);
assert_eq!(musical.line_at_or_before(line, sr, &map, rate), line);
}
for absolute in [
GridValue::Seconds(0.5),
GridValue::Timecode(TimecodeValue::Second),
GridValue::FeetFrames(FeetFramesValue::Foot),
GridValue::Samples(1_000),
] {
let line = absolute.next_line(absolute.line_at_or_before(100_000, sr, &map, rate), sr, &map, rate);
assert_eq!(absolute.line_at_or_before(line, sr, &map, rate), line, "{absolute:?}");
}
}
#[test]
fn sixteenth_grid_at_120() {
let g = GridValue::default();
@@ -248,14 +654,33 @@ mod tests {
assert_eq!(g.lines(0, 24_000, sr, &map, rate, 100), vec![0, 6000, 12000, 18000]);
}
/// Absolute increments report their expected sample lengths at a fixed rate.
#[test]
fn absolute_grids() {
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
assert_eq!(GridValue::Seconds(1.0).snap(70_000, sr, &map, rate), 48_000);
assert_eq!(GridValue::Frames(1).step_samples(0, sr, &map, rate), 1920);
assert_eq!(GridValue::Timecode(TimecodeValue::Second).step_samples(0, sr, &map, rate), 48_000);
assert_eq!(GridValue::Timecode(TimecodeValue::FiveFrames).step_samples(0, sr, &map, rate), 9_600);
assert_eq!(GridValue::Timecode(TimecodeValue::HalfFrame).step_samples(0, sr, &map, rate), 960);
assert_eq!(GridValue::Timecode(TimecodeValue::QuarterFrame).step_samples(0, sr, &map, rate), 480);
assert_eq!(GridValue::Timecode(TimecodeValue::SubFrame).step_samples(0, sr, &map, rate), 19);
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Foot).step_samples(0, sr, &map, rate), 30_720);
assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1);
}
/// Bar length follows the meter while a whole note always spans four quarter notes.
#[test]
fn a_bar_and_a_whole_note_differ_in_three_four() {
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::new(120.0, 3, 4).unwrap(), FrameRate::Fps25);
let note = |value| GridValue::Note { value, dotted: false, triplet: false };
assert_eq!(note(NoteValue::Bar).step_samples(0, sr, &map, rate), 72_000);
assert_eq!(note(NoteValue::Whole).step_samples(0, sr, &map, rate), 96_000);
assert_eq!(note(NoteValue::Bar).line_at_or_before(96_000, sr, &map, rate), 72_000);
assert_eq!(note(NoteValue::Whole).line_at_or_before(96_000, sr, &map, rate), 96_000);
assert_eq!(note(NoteValue::Half).lines(0, 150_000, sr, &map, rate, 10), [0, 48_000, 72_000, 120_000, 144_000]);
}
#[test]
fn seconds_grid_lines_land_on_whole_seconds() {
let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30);
@@ -268,6 +693,7 @@ mod tests {
}
}
/// Legacy frame grids stay aligned to exact frame starts at fractional rates.
#[test]
fn frame_grid_lines_land_on_frame_starts() {
let sr = SampleRate::HZ_48000;
@@ -291,6 +717,55 @@ mod tests {
}
}
/// Named whole- and sub-frame grids retain the exact-multiple calculation used by legacy frame grids.
#[test]
fn named_frame_grids_land_on_exact_subdivisions() {
let sr = SampleRate::HZ_48000;
let map = TempoMap::default();
let grids = TimecodeValue::ALL.into_iter().map(GridValue::Timecode).chain(FeetFramesValue::ALL.into_iter().map(GridValue::FeetFrames));
for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] {
for grid in grids.clone() {
for index in 0..200 {
let line = grid.abs_line(index, sr, rate);
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{grid:?} {rate:?} line {index}");
assert_eq!(grid.next_line(line, sr, &map, rate), grid.abs_line(index + 1, sr, rate), "{grid:?} {rate:?} line {index}");
}
}
}
}
/// Drop-frame second grids follow labelled second boundaries in both timeline directions.
#[test]
fn drop_frame_second_grids_follow_timecode_labels() {
let sr = SampleRate::HZ_48000;
let map = TempoMap::default();
let grid = GridValue::Timecode(TimecodeValue::Second);
for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
for seconds in [0, 59, 60, 61, 600, 601, 3600] {
let frame = GridValue::drop_second_frame(seconds, rate);
let line = rate.samples_at_frame(frame, sr);
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{rate:?} second {seconds}");
assert_eq!(grid.line_at_or_before(-line, sr, &map, rate), -line, "{rate:?} second -{seconds}");
for offset in -2..=rate.nominal() + 2 {
let position = rate.samples_at_frame(frame.saturating_add(offset), sr);
let before = grid.line_at_or_before(position, sr, &map, rate);
assert!(before <= position, "{rate:?} second {seconds}, frame offset {offset}");
assert!(grid.next_line(before, sr, &map, rate) > position, "{rate:?} second {seconds}, frame offset {offset}");
}
}
let labelled = Timecode { negative: false, hours: 0, minutes: 1, seconds: 1, frames: 0 }.to_samples(rate, sr);
assert_eq!(grid.snap(labelled, sr, &map, rate), labelled);
}
for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1800), (FrameRate::Fps59_94Drop, 3600)] {
let boundary = rate.samples_at_frame(minute_frame, sr);
assert_eq!(grid.line_at_or_before(boundary, sr, &map, rate), boundary, "{rate:?} positive minute boundary");
assert_eq!(grid.snap(boundary, sr, &map, rate), boundary, "{rate:?} positive minute snap");
assert_eq!(grid.line_at_or_before(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute boundary");
assert_eq!(grid.snap(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute snap");
}
}
#[test]
fn lines_are_capped() {
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
@@ -306,6 +781,7 @@ mod tests {
assert_eq!(d.step_samples(0, sr, &map, rate), 36_000);
}
/// Stepping follows each menu ladder and stops at its endpoints.
#[test]
fn values_step_to_the_next_larger_or_smaller() {
let note = |value, triplet| GridValue::Note { value, dotted: false, triplet };
@@ -315,9 +791,12 @@ mod tests {
assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false));
assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1));
assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1));
assert_eq!(GridValue::Seconds(10.0).stepped(true), GridValue::Seconds(10.0));
assert_eq!(GridValue::Seconds(1.0).stepped(true), GridValue::Seconds(1.0));
assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10));
assert_eq!(GridValue::Timecode(TimecodeValue::Frame).stepped(false), GridValue::Timecode(TimecodeValue::HalfFrame));
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Frame).stepped(true), GridValue::FeetFrames(FeetFramesValue::Foot));
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
assert_eq!(GridValue::Samples(1).stepped(true), GridValue::Samples(2));
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
}
+1 -1
View File
@@ -12,7 +12,7 @@ mod tempo;
mod timecode;
pub use format::{TimeFormat, format_length, format_position, parse_position};
pub use grid::{GridValue, NoteValue};
pub use grid::{FeetFramesValue, GridScale, GridValue, NoteValue, TimecodeValue};
pub use tempo::{BarBeat, MeterEvent, TICKS_PER_QUARTER, TempoEvent, TempoMap};
pub use timecode::{FrameRate, Timecode};
+211 -38
View File
@@ -40,14 +40,34 @@ pub struct BarBeat {
/// Tempo and meter changes for a session. Always has at least one of each, at tick 0.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(from = "TempoMapData")]
pub struct TempoMap {
tempos: Vec<TempoEvent>,
meters: Vec<MeterEvent>,
/// For each tempo event, the elapsed time at its tick in quarter-note minutes (ticks / bpm,
/// summed over the earlier segments). Lets tick/sample conversions binary-search the segment
/// instead of walking every earlier tempo change, so converting many positions against a large
/// tempo map stays O(log n) each. Rebuilt whenever `tempos` changes.
#[serde(skip)]
elapsed: Vec<f64>,
}
/// The serialized form of [`TempoMap`], without the derived lookup table.
#[derive(serde::Deserialize)]
struct TempoMapData {
tempos: Vec<TempoEvent>,
meters: Vec<MeterEvent>,
}
impl From<TempoMapData> for TempoMap {
fn from(d: TempoMapData) -> Self {
TempoMap::from_parts(d.tempos, d.meters)
}
}
impl Default for TempoMap {
fn default() -> Self {
TempoMap { tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }], meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }] }
TempoMap::from_parts(vec![TempoEvent { tick: 0, bpm: 120.0 }], vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }])
}
}
@@ -60,6 +80,43 @@ fn valid_meter(numerator: u32, denominator: u32) -> bool {
}
impl TempoMap {
fn from_parts(tempos: Vec<TempoEvent>, meters: Vec<MeterEvent>) -> Self {
let mut m = TempoMap { tempos, meters, elapsed: Vec::new() };
m.reindex();
m
}
/// Rebuild `elapsed` from `tempos`. The first segment runs from tick 0 at the first tempo.
fn reindex(&mut self) {
let mut elapsed = Vec::with_capacity(self.tempos.len());
let mut acc = 0.0;
let mut seg_tick = 0i64;
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
for (i, e) in self.tempos.iter().enumerate() {
if i > 0 {
// Saturating: a file's ticks are indexed before `sanitize` orders them.
acc += e.tick.saturating_sub(seg_tick) as f64 / bpm;
seg_tick = e.tick;
bpm = e.bpm;
}
elapsed.push(acc);
}
self.elapsed = elapsed;
}
/// Samples per quarter-note minute (the unit of `elapsed`) at `sr`.
fn samples_per_elapsed(sr: SampleRate) -> f64 {
sr.as_f64() * 60.0 / TICKS_PER_QUARTER as f64
}
/// The tempo segment `i` (an index into `tempos`, 0 when it is empty): its start tick (0 for
/// the first), bpm and elapsed time at its start.
fn segment(&self, i: usize) -> (i64, f64, f64) {
let bpm = self.tempos.get(i).or(self.tempos.first()).map_or(120.0, |e| e.bpm);
let tick = if i == 0 { 0 } else { self.tempos.get(i).map_or(0, |e| e.tick) };
(tick, bpm, self.elapsed.get(i).copied().unwrap_or(0.0))
}
pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result<Self, TimeError> {
let mut m = TempoMap::default();
m.set_tempo(0, bpm)?;
@@ -91,6 +148,7 @@ impl TempoMap {
let (numerator, denominator) = self.meters.first().map_or((4, 4), |m| (m.numerator, m.denominator));
self.meters.insert(0, MeterEvent { tick: 0, numerator, denominator });
}
self.reindex();
}
/// Insert or replace the tempo at `tick`.
@@ -105,6 +163,7 @@ impl TempoMap {
}
Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }),
}
self.reindex();
Ok(())
}
@@ -115,6 +174,7 @@ impl TempoMap {
}
let before = self.tempos.len();
self.tempos.retain(|e| e.tick != tick);
self.reindex();
before != self.tempos.len()
}
@@ -153,18 +213,52 @@ impl TempoMap {
}
}
/// Remove the meters after `tick`, returning each with the bar it starts.
/// Remove the meters after `tick`, returning each with the bar it starts: the bar
/// [`Self::bar_beat_at_tick`] gives its tick, counted in one pass over the map rather than one
/// walk per meter, so a session with many meter changes edits in linear time.
fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> {
let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect();
let mut later = Vec::new();
let (mut bar, mut seg_tick) = (1i64, 0i64);
for (i, m) in self.meters.iter().enumerate() {
let tpb = m.ticks_per_bar();
if m.tick > tick {
later.push((bar.saturating_add(m.tick.saturating_sub(seg_tick) / tpb), *m));
}
if let Some(n) = self.meters.get(i + 1) {
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
seg_tick = n.tick;
}
}
self.meters.retain(|e| e.tick <= tick);
later
}
/// The bar and tick of the last meter's segment, where [`Self::bar_start_tick`]'s walk ends.
fn last_segment(&self) -> (i64, i64) {
let (mut cur_bar, mut cur_tick) = (1i64, 0i64);
for (i, m) in self.meters.iter().enumerate() {
if let Some(n) = self.meters.get(i + 1) {
cur_bar = cur_bar.saturating_add(ceil_div(n.tick.saturating_sub(cur_tick), m.ticks_per_bar()));
cur_tick = n.tick;
}
}
(cur_bar, cur_tick)
}
/// Put meters back at the start of their bars under the current earlier meters, in bar order.
/// Each lands at or past the last meter, so the walk to its bar resumes from there; a bar
/// before it (meters off their bar lines) takes the full walk.
fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) {
let (mut cur_bar, mut cur_tick) = self.last_segment();
for (bar, ev) in later {
let tick = self.bar_start_tick(bar);
let tick = if bar >= cur_bar {
let tpb = self.meters.last().map_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }, |m| *m).ticks_per_bar();
cur_tick.saturating_add(bar.saturating_sub(cur_bar).saturating_mul(tpb))
} else {
self.bar_start_tick(bar)
};
self.put_meter(MeterEvent { tick, ..ev });
(cur_bar, cur_tick) = if bar >= cur_bar { (bar, tick) } else { self.last_segment() };
}
}
@@ -204,36 +298,20 @@ impl TempoMap {
}
fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 {
let mut acc = 0.0;
let mut seg_tick = 0i64;
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
for e in self.tempos.iter().skip(1) {
if e.tick >= tick {
break;
}
acc += (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr);
seg_tick = e.tick;
bpm = e.bpm;
}
acc + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr)
// Tempo events after the first that start before `tick` (the segments it has passed).
let passed = self.tempos.get(1..).map_or(0, |rest| rest.partition_point(|e| e.tick < tick));
let (seg_tick, bpm, elapsed) = self.segment(passed);
elapsed * Self::samples_per_elapsed(sr) + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr)
}
/// Samples → ticks (fractional).
pub fn samples_to_ticks_f(&self, s: Samples, sr: SampleRate) -> f64 {
let s = s as f64;
let mut acc = 0.0;
let mut seg_tick = 0i64;
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
for e in self.tempos.iter().skip(1) {
let seg = (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr);
if acc + seg > s {
break;
}
acc += seg;
seg_tick = e.tick;
bpm = e.bpm;
}
seg_tick as f64 + (s - acc) / Self::samples_per_tick(bpm, sr)
let scale = Self::samples_per_elapsed(sr);
// Tempo events after the first that start at or before `s`.
let passed = self.elapsed.get(1..).map_or(0, |rest| rest.partition_point(|e| e * scale <= s));
let (seg_tick, bpm, elapsed) = self.segment(passed);
seg_tick as f64 + (s - elapsed * scale) / Self::samples_per_tick(bpm, sr)
}
pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 {
@@ -319,6 +397,7 @@ impl TempoMap {
e.bpm = valid_bpm(e.bpm * factor)?;
}
self.tempos = out;
self.reindex();
Ok(())
}
}
@@ -396,6 +475,36 @@ mod tests {
assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb);
}
#[test]
fn editing_an_early_meter_keeps_later_bars_and_scales_linearly() {
// 200 000 meter changes, one per bar, built directly: a walk per meter would take
// minutes to edit here, and even to build through `set_meter`.
let mut m = TempoMap::new(120.0, 4, 4).unwrap();
let mut tick = 0;
for bar in 1..200_000i64 {
let numerator = 3 + (bar % 2) as u32;
tick += m.meters.last().map_or(0, MeterEvent::ticks_per_bar);
m.meters.push(MeterEvent { tick, numerator, denominator: 4 });
}
let sample = [1usize, 1_000, 199_998];
let bars = |m: &TempoMap| sample.map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar);
assert_eq!(bars(&m), [2, 1_001, 199_999]);
m.set_meter(0, 7, 8).unwrap();
assert_eq!(bars(&m), [2, 1_001, 199_999], "later meters keep their bar numbers");
assert_eq!(m.meters.len(), 200_000);
assert!(m.meters.windows(2).all(|w| w[0].tick < w[1].tick));
assert!(m.remove_meter(m.meters[1].tick));
assert_eq!([1usize, 999, 199_997].map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar), [3, 1_001, 199_999]);
// Meters off their bar lines (counted as bars 2 and 4) land where they always did: on
// bar 2 of the new meter, then two bars of their own later.
let mut off = TempoMap::new(120.0, 4, 4).unwrap();
off.meters.push(MeterEvent { tick: 100, numerator: 3, denominator: 4 });
off.meters.push(MeterEvent { tick: 5_000, numerator: 5, denominator: 4 });
off.set_meter(0, 2, 4).unwrap();
let placed: Vec<(i64, u32)> = off.meters.iter().map(|e| (e.tick, e.numerator)).collect();
assert_eq!(placed, vec![(0, 2), (2 * TICKS_PER_QUARTER, 3), (8 * TICKS_PER_QUARTER, 5)]);
}
#[test]
fn eighth_note_meters() {
let m = TempoMap::new(120.0, 6, 8).unwrap();
@@ -407,24 +516,24 @@ mod tests {
fn sanitize_keeps_what_the_setters_accept() {
let tempo = |tick, bpm| TempoEvent { tick, bpm };
let meter = |tick, numerator, denominator| MeterEvent { tick, numerator, denominator };
let mut m = TempoMap {
tempos: vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)],
meters: vec![meter(0, 0, 4), meter(3840, 3, 4)],
};
let mut m = TempoMap::from_parts(
vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)],
vec![meter(0, 0, 4), meter(3840, 3, 4)],
);
m.sanitize();
assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]);
assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]);
let mut empty = TempoMap { tempos: Vec::new(), meters: Vec::new() };
let mut empty = TempoMap::from_parts(Vec::new(), Vec::new());
empty.sanitize();
assert_eq!(empty, TempoMap::default());
}
#[test]
fn sanitize_drops_events_beyond_the_position_range() {
let mut m = TempoMap {
tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }],
meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }],
};
let mut m = TempoMap::from_parts(
vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }],
vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }],
);
m.sanitize();
assert_eq!(m, TempoMap::default());
assert_eq!(m.bar_start_tick(2), TICKS_PER_QUARTER * 4);
@@ -446,4 +555,68 @@ mod tests {
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER * 4), BarBeat { bar: 0, beat: 1, tick: 0 });
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER), BarBeat { bar: 0, beat: 4, tick: 0 });
}
/// The segment walk the indexed conversions replaced, kept as the reference they must match.
fn linear_tick_to_samples(m: &TempoMap, tick: i64, sr: SampleRate) -> i64 {
let mut acc = 0.0;
let mut seg_tick = 0i64;
let mut bpm = m.tempos[0].bpm;
for e in m.tempos.iter().skip(1) {
if e.tick >= tick {
break;
}
acc += (e.tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr);
seg_tick = e.tick;
bpm = e.bpm;
}
to_samples(acc + (tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr))
}
#[test]
fn indexed_conversions_match_the_segment_walk() {
let sr = SampleRate::HZ_48000;
let mut m = TempoMap::default();
for i in 1..200i64 {
m.set_tempo(i * 487, 40.0 + ((i * 37) % 300) as f64).unwrap();
}
assert!(m.remove_tempo(487 * 50));
for tick in (-1000..200 * 487 + 5000).step_by(113) {
let s = m.tick_to_samples(tick, sr);
assert_eq!(s, linear_tick_to_samples(&m, tick, sr), "tick {tick}");
assert!((m.samples_to_ticks(s, sr) - tick).abs() <= 1, "tick {tick}");
}
let json = serde_json::to_string(&m).unwrap();
assert!(!json.contains("elapsed"));
let back: TempoMap = serde_json::from_str(&json).unwrap();
assert_eq!(back, m);
assert_eq!(back.tick_to_samples(150 * 487 + 3, sr), m.tick_to_samples(150 * 487 + 3, sr));
}
#[test]
fn a_file_with_wild_ticks_is_indexed_without_overflow() {
let json = r#"{"tempos":[{"tick":0,"bpm":120.0},{"tick":-9223372036854775808,"bpm":100.0},{"tick":9223372036854775807,"bpm":90.0}],"meters":[{"tick":0,"numerator":4,"denominator":4}]}"#;
let mut m: TempoMap = serde_json::from_str(json).unwrap();
m.sanitize();
assert_eq!(m.tempos().first().map(|e| e.tick), Some(0));
assert_eq!(m.elapsed.len(), m.tempos().len());
}
#[test]
fn conversions_against_a_large_tempo_map_stay_fast() {
// 200k tempo changes, then 200k conversions each way: quadratic if every conversion walks
// the earlier segments, well under a second when it binary-searches them.
let sr = SampleRate::HZ_48000;
let n = 200_000i64;
let tempos = (0..n).map(|i| TempoEvent { tick: i * 10, bpm: 60.0 + (i % 7) as f64 }).collect();
let mut m = TempoMap::from_parts(tempos, vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]);
m.sanitize();
let start = std::time::Instant::now();
let mut sum = 0i64;
for i in 0..n {
let s = m.tick_to_samples(i * 10 + 5, sr);
sum = sum.wrapping_add(s).wrapping_add(m.samples_to_ticks(s, sr));
}
assert!(sum != 0);
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
}
}
+1
View File
@@ -24,6 +24,7 @@ soundcraft-video.workspace = true
# 0.37 makes the bundled faces opt-in; the app's own font loader starts from
# `FontDefinitions::default()`, and kittest never supplies fonts to this root.
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_extras = { workspace = true }
web-time.workspace = true
serde.workspace = true
serde_json.workspace = true
+5 -1
View File
@@ -3,7 +3,7 @@ use super::state::MainWindow;
use super::{
ControlRequest, Engine, Gesture, HashMap, MeterDisplay, OutputTrouble, Player, Range, Receiver, Samples, SoundApp, TrackId, TransportRequest,
Trouble, UiState, Value, VecDeque, bounce, dialogs, edit_window, extra_windows, fonts, import, json, menus, midi_editor, mix_window, ops_windows,
palette, panels, recovery, sampler_window, score_editor, shortcuts, theme, updates_ui, video_track, video_window, windows,
palette, panels, preferences, recovery, sampler_window, score_editor, shortcuts, theme, updates_ui, video_track, video_window, windows,
};
mod audio;
@@ -63,6 +63,7 @@ impl SoundApp {
ops: ops_windows::OpsState::default(),
extra: extra_windows::ExtraState::default(),
palette: palette::PaletteState::default(),
preferences: preferences::PreferencesState::default(),
focus_mix: false,
edit_scroll_into_view: None,
mix_scroll_into_view: None,
@@ -137,6 +138,8 @@ impl SoundApp {
/// Each host iteration starts with `raw_input_hook`; its editor-poll admission prevents an
/// empty queue on a discarded pass from ending a still-active plugin gesture.
pub fn logic(&mut self, ctx: &egui::Context) {
self.ui.undo_levels = self.ui.undo_levels.clamp(1, 64);
self.engine.set_history_limit(self.ui.undo_levels);
// Fonts set now take effect next frame, so draw only from the frame after.
if !self.fonts_ready {
if self.fonts_installed {
@@ -333,6 +336,7 @@ impl SoundApp {
}
panels::floating(self, &ctx);
windows::show(self, &ctx);
preferences::show(self, &ctx);
video_window::show(self, &ctx);
ops_windows::show(self, &ctx);
extra_windows::show(self, &ctx);
+322 -53
View File
@@ -262,71 +262,213 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
ui.separator();
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
table(ui, &list, &mut v);
} else {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
// once its columns are dragged wider than the dialog.
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
} else {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
contributors_grab_bag(ui, &list, v.names);
}
});
});
}
ui.data_mut(|d| d.insert_temp(id, v));
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ⏶" } else { " ⏷" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
ui.label(group(c.lines_deleted));
ui.label(signed(c.lines_delta()));
ui.label(group(c.binary_added));
ui.label(group(c.binary_deleted));
ui.label(day(c.first_commit));
ui.label(day(c.last_commit));
ui.end_row();
}
});
/// How the contributor table's spare width is shared out: all of it to Name.
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
/// How the model table's spare width is shared out: to Company and Model, the text columns.
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
/// The narrowest a column can be dragged.
const MIN_COLUMN: f32 = 28.0;
/// The width a column needs for its header and its widest cell.
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
widest + 2.0 * ui.spacing().button_padding.x
}
/// About › Models: AI models credited in Co-Authored-By trailers.
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if *g > 0.0 && spare >= 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
w - (-spare).min(flexible) * slack(*w) / flexible
} else {
*w
}
})
.collect()
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
/// dropping any dragged widths.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
// The table keeps its own column widths, so a change in what they were fitted to has to clear
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
let fit = egui::Id::unique(("credits_fit", id));
let measured = (natural.to_vec(), ui.clip_rect().width());
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
ui.data_mut(|d| d.insert_temp(fit, measured));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
if refit {
t.reset();
}
for w in widths {
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
}
t
}
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
ui.with_layout(layout, add).inner
}
fn row_height(ui: &egui::Ui) -> f32 {
ui.text_style_height(&egui::TextStyle::Body) + 6.0
}
/// One contributor's table cells after the name, in `SortKey::ALL` order.
fn cells(c: &Contributor) -> [String; 9] {
[
group(c.prs),
group(c.commits),
group(c.lines_added),
group(c.lines_deleted),
signed(c.lines_delta()),
group(c.binary_added),
group(c.binary_deleted),
day(c.first_commit).to_string(),
day(c.last_commit).to_string(),
]
}
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
/// toolbar's sort-order button, so no arrow widens the column.
fn header_text(k: SortKey) -> RichText {
RichText::new(k.label().1).strong()
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
let h = row_height(ui);
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
let widths: Vec<f32> = SortKey::ALL
.iter()
.enumerate()
.map(|(i, &k)| match i.checked_sub(1) {
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
})
.collect();
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
.header(h + 4.0, |mut header| {
for k in SortKey::ALL {
header.col(|ui| {
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0);
if r.clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
}
})
.body(|mut body| {
for (c, cells) in list.iter().zip(rows) {
body.row(h, |mut row| {
row.col(|ui| {
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
.on_hover_text(c.summary());
});
for text in cells {
row.col(|ui| {
aligned(ui, true, |ui| ui.label(text));
});
}
});
}
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
pub fn models_ui(ui: &mut egui::Ui) {
if MODELS.is_empty() {
ui.label("No model credits were built into this copy.");
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for m in MODELS {
ui.label(m.company);
ui.label(m.model);
ui.label(m.version);
ui.label(group(m.commits));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
ui.end_row();
}
});
let h = row_height(ui);
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
// Company, Model and Version read as text; the counts line up on the right.
let right = [false, false, false, true, true, true];
let rows: Vec<[String; 6]> = MODELS
.iter()
.map(|m| {
[
m.company.to_string(),
m.model.to_string(),
m.version.to_string(),
group(m.commits),
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
]
})
.collect();
let widths: Vec<f32> = titles
.iter()
.enumerate()
.map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
.collect();
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
.header(h + 4.0, |mut header| {
for (title, r) in titles.into_iter().zip(right) {
header.col(|ui| {
aligned(ui, r, |ui| ui.label(RichText::new(title).strong()));
});
}
})
.body(|mut body| {
for cells in rows {
body.row(h, |mut row| {
for (text, r) in cells.into_iter().zip(right) {
row.col(|ui| {
aligned(ui, r, |ui| ui.label(text));
});
}
});
}
});
});
}
@@ -406,4 +548,131 @@ mod tests {
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
#[test]
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
}
#[test]
fn too_little_room_shrinks_only_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
#[test]
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
}
const WINDOW: f32 = 640.5;
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
/// One frame of a table long enough to scroll; the rects of its header cells.
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
scrolled_frame(ctx, width, natural, 0.0, events)
}
/// [`table_frame`], scrolled `offset` points sideways.
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
let mut cells = Vec::new();
let out = ctx.run_ui(raw, |ui| {
cells.clear();
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
fitted_table(ui, "test", natural, &GROW)
.header(20.0, |mut header| {
for _ in natural {
header.col(|ui| cells.push(ui.max_rect()));
}
})
.body(|mut body| {
for _ in 0..40 {
body.row(20.0, |mut row| {
for _ in natural {
row.col(|ui| {
ui.label("x");
});
}
});
}
});
});
});
out.drop_without_applying_deltas();
cells
}
/// A new context with the table open and settled, and the rects of its header cells.
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
let ctx = egui::Context::default();
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
(ctx, cells)
}
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
use egui::{Event, Modifiers, PointerButton};
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
let halfway = start + egui::vec2(by / 2.0, 0.0);
let end = start + egui::vec2(by, 0.0);
let moved = Event::PointerMoved;
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
}
}
#[test]
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
let (_, opened) = opened_table();
for (dragged, cell) in opened.iter().enumerate() {
let (ctx, _) = opened_table();
drag_edge(&ctx, *cell, -30.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
}
}
}
#[test]
fn a_changed_fit_replaces_the_column_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], -30.0);
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
}
#[test]
fn scrolling_sideways_keeps_dragged_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], 100.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
for offset in [13.3, 27.77, 41.1, 0.6] {
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
}
}
}
+274 -69
View File
@@ -20,9 +20,10 @@ use audiosuite::SPEED_RAMP_CHOICES;
use audiosuite::audiosuite_window;
pub use files::{NotUnicode, ask_to_save, open_path, open_session_bytes, run_or_ask, takes_path, unicode};
use files::{OPEN_PENDING, default_dir, open_pending_session, path_dialog};
use strip_silence::{AUTO_NAME_LABELS, STRIP_NAME_LABELS, StripRectangles, auto_rename_action, strip_name_fields, strip_silence_action};
use strip_silence::{AUTO_NAME_LABELS, STRIP_NAME_LABELS, StripRectangles, StripValues, auto_rename_action, strip_name_fields, strip_silence_action};
pub use strip_silence::{StripRects, strip_silence_rectangles};
pub mod new_tracks;
pub mod ops_windows;
pub(super) mod recent;
pub mod recovery;
@@ -30,7 +31,7 @@ pub mod spot;
#[derive(Debug, Clone)]
pub enum Dialog {
NewTracks { count: u32, format: String, kind: String, timebase: String, name: String },
NewTracks(new_tracks::NewTracks),
NewSession { name: String, sample_rate: u32, demo: bool },
Bounce { path: String, format: FileFormat, bit_depth: String, normalize: bool, dither: bool },
RenameTrack { id: TrackId, name: String },
@@ -77,14 +78,17 @@ pub struct Dialogs {
pending_session: Option<(String, Vec<u8>)>,
/// The naming scheme Strip Silence gives the clips it leaves, kept from one use to the next.
pub strip_names: StripNames,
/// The values Strip Silence last had, which it opens with next time.
strip_values: StripValues,
/// The Strip Silence rectangles the Edit window draws while Strip Silence is open.
strip_rectangles: StripRectangles,
}
impl Dialogs {
/// Returns the stable UI identifier of the currently open dialog.
pub fn open_name(&self) -> Option<&'static str> {
self.open.as_ref().map(|d| match d {
Dialog::NewTracks { .. } => "new_tracks",
Dialog::NewTracks(_) => "new_tracks",
Dialog::NewSession { .. } => "new_session",
Dialog::Bounce { .. } => "bounce",
Dialog::RenameTrack { .. } => "rename_track",
@@ -166,9 +170,7 @@ impl Dialogs {
let ed = &e.session().edit;
return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone());
}
"track.new" => {
Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
}
"track.new" => Dialog::NewTracks(new_tracks::NewTracks::default()),
"session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false },
"file.bounce_mix" => {
let dir = e
@@ -211,7 +213,10 @@ impl Dialogs {
Dialog::Number { cmd: id.into(), title: "Quantize strength".into(), key: "strength".into(), value: 100.0, suffix: "%".into() }
}
"edit.fades_create" => Dialog::Fades { shape: "equal power".into() },
"edit.strip_silence" => Dialog::StripSilence { threshold: -48.0, min_ms: 50.0, pre_ms: 5.0, post_ms: 20.0 },
"edit.strip_silence" => {
let StripValues([threshold, min_ms, pre_ms, post_ms]) = self.strip_values;
Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }
}
"track.group" => {
let s = e.session();
let members = s.tracks.iter().map(|t| (t.id.0, t.name.clone(), s.edit.selected_tracks.contains(&t.id))).collect();
@@ -281,54 +286,6 @@ fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option<
Some(text)
}
/// The New Tracks form; the command to run once Create is pressed.
fn new_tracks_form(
ui: &mut egui::Ui,
count: &mut u32,
format: &mut String,
kind: &mut String,
timebase: &mut String,
name: &mut String,
enter: bool,
) -> Option<(String, Value)> {
ui.horizontal(|ui| {
let create = ui.label("Create");
ui.add(egui::DragValue::new(count).range(1..=128)).labelled_by(create.id);
ui.label("new");
let fmt = craft_ui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| {
for f in soundcraft_model::ChannelFormat::ALL {
ui.selectable_value(format, f.label().to_string(), f.label());
}
});
fmt.response.accessible_name("Channel format");
let kind_combo = craft_ui::ComboBox::from_id_salt("nt_kind")
.selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label()))
.show_ui(ui, |ui| {
for k in soundcraft_model::TrackKind::ALL {
if ui.selectable_label(kind == k.id(), k.label()).clicked() {
*kind = k.id().to_string();
*name = k.default_name().to_string();
}
}
});
kind_combo.response.accessible_name("Track kind");
ui.label("in");
let tb = craft_ui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui(ui, |ui| {
ui.selectable_value(timebase, "samples".to_string(), "Samples");
ui.selectable_value(timebase, "ticks".to_string(), "Ticks");
});
tb.response.accessible_name("Timebase");
});
ui.horizontal(|ui| {
ui.label("Name:");
crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name");
});
if buttons(ui, "Create", enter) {
return Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name})));
}
None
}
/// The Bounce form's file row: a path field and a Browse button that also picks the format.
fn bounce_file_row(app: &mut SoundApp, ui: &mut egui::Ui, path: &mut String, format: &mut FileFormat) {
ui.horizontal(|ui| {
@@ -462,6 +419,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let Some(mut d) = app.dialogs.open.take() else { return };
let mut keep = true;
let mut action: Option<(String, Value)> = None;
let mut bounce_params: Option<Value> = None;
let mut save_first: Option<(String, Value)> = None;
let confirmed = matches!(d, Dialog::SaveChanges { .. });
// Strip Silence's Rename button, and the naming scheme its dialog's OK keeps.
@@ -469,7 +427,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let mut renamed: Option<StripNames> = None;
let strip_names = app.dialogs.strip_names.clone();
let title = match &d {
Dialog::NewTracks { .. } => "New Tracks",
Dialog::NewTracks(_) => "New Tracks",
Dialog::NewSession { .. } => "New Session",
Dialog::Bounce { .. } => "Bounce Mix",
Dialog::RenameTrack { .. } => "Rename Track",
@@ -498,11 +456,15 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0))
.show(ctx, |ui| {
// Escape owns the whole frame: no focused button or other control may act first.
if esc {
return;
}
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
match &mut d {
Dialog::NewTracks { count, format, kind, timebase, name } => {
if let Some(cmd) = new_tracks_form(ui, count, format, kind, timebase, name, enter) {
action = Some(cmd);
Dialog::NewTracks(tracks) => {
if let Some(params) = tracks.show(ui, enter) {
action = Some(("track.new".into(), params));
}
}
Dialog::NewSession { name, sample_rate, demo } => {
@@ -550,11 +512,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
);
if buttons(ui, "Bounce", enter) {
let p = std::path::Path::new(path.as_str()).with_extension(format.default_extension());
crate::bounce::start(
app,
&json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}),
bounce_params = Some(
json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}),
);
keep = false;
}
}
Dialog::RenameTrack { id, name } => {
@@ -757,16 +717,23 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
}
});
// Strip Silence keeps the values it shows, however it closes: Escape, Cancel or one of its actions.
match &d {
Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => {
app.dialogs.strip_values = StripValues([*threshold, *min_ms, *pre_ms, *post_ms])
}
Dialog::StripSilenceRename { strip, .. } => app.dialogs.strip_values = StripValues(*strip),
_ => {}
}
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::unique("dlg_cancel")).unwrap_or(false)) {
keep = false;
action = None;
bounce_params = None;
save_first = None;
app.dialogs.after_save = None;
app.dialogs.pending_session = None;
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::unique("dlg_cancel")));
}
// Enter on a focused Cancel also presses OK. In Auto Rename, the dialog then closes without running the command.
if !keep && matches!(d, Dialog::AutoRename { .. }) {
action = None;
}
if let Some(strip) = rename_from
&& keep
{
@@ -794,6 +761,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
app.dialogs.after_save = Some(then);
}
if let Some(params) = bounce_params {
keep = false;
crate::bounce::start(app, &params);
}
if let Some((cmd, params)) = &action
&& !confirmed
&& ask_to_save(app, cmd, params)
@@ -844,11 +815,13 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
}
/// `buttons`, but a greyed-out OK when the dialog's values can't be submitted yet. Cancel always works.
fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool {
pub(crate) fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool {
let mut pressed = enter && ready;
let mut canceled = false;
ui.separator();
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
canceled = true;
ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::unique("dlg_cancel"), true));
}
let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent);
@@ -856,7 +829,7 @@ fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> boo
pressed = true;
}
});
pressed
pressed && !canceled
}
/// A name field that takes the focus with its text selected, so typing replaces the old name.
@@ -915,6 +888,238 @@ fn loop_length(s: &Session, text: &str, format: soundcraft_time::TimeFormat, sta
#[cfg(test)]
mod tests {
use super::*;
use crate::dialogs::new_tracks::set_new_track_kind;
/// Opens and settles the dialog for a chosen desktop operating system.
fn tracks_dialog(os: egui::os::OperatingSystem) -> (egui::Context, SoundApp) {
let ctx = egui::Context::default();
ctx.set_os(os);
crate::fonts::install(&ctx, true);
let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
for _ in 0..3 {
dialog_frame(&ctx, &mut app, vec![]);
}
(ctx, app)
}
/// Runs one dialog frame with synthetic input events.
fn dialog_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<egui::Event>) {
let raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
let mut output = ctx.run_ui(raw, |ui| {
crate::shortcuts::handle(app, ui.ctx());
show(app, ui.ctx());
});
output.textures_delta.clear();
}
/// Constructs one non-repeating key-press event for dialog tests.
fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
}
/// Returns every editable value from the active New Tracks row.
fn track_fields(app: &SoundApp) -> (u32, &str, &str, &str, &str) {
match app.dialogs.open.as_ref().unwrap() {
Dialog::NewTracks(tracks) => {
let row = tracks.active_row().unwrap();
(row.count, &row.format, &row.kind, &row.timebase, &row.name)
}
other => panic!("unexpected dialog: {other:?}"),
}
}
/// Opening selects the count, and bare arrows adjust it within its bounds.
#[test]
fn new_tracks_focus_selects_count_and_arrows_clamp_it() {
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
dialog_frame(&ctx, &mut app, vec![egui::Event::Text("7".into())]);
assert_eq!(track_fields(&app).0, 7, "typing must replace the selected initial value");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]);
assert_eq!(track_fields(&app).0, 8);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]);
assert_eq!(track_fields(&app).0, 7);
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
tracks.rows[0].count = 128;
}
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]);
assert_eq!(track_fields(&app).0, 128);
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
tracks.rows[0].count = 1;
}
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]);
assert_eq!(track_fields(&app).0, 1);
}
/// macOS command-arrow families edit format, type and timebase without changing count.
#[test]
fn new_tracks_mac_chords_change_settings_without_changing_count() {
for ctrl in [false, true] {
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
let cmd = egui::Modifiers { command: true, mac_cmd: true, ctrl, ..Default::default() };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]);
assert_eq!(track_fields(&app), (1, "Stereo", "audio", "samples", "Audio"));
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]);
assert_eq!(track_fields(&app).1, "Mono");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]);
assert_eq!(track_fields(&app).1, soundcraft_model::ChannelFormat::ALL.last().unwrap().label());
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
assert_eq!(track_fields(&app), (1, "Mono", "aux", "samples", "Aux"));
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]);
assert_eq!(track_fields(&app).2, "audio");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]);
assert_eq!(track_fields(&app).2, "video");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
assert_eq!(track_fields(&app).4, "Audio");
assert!(app.engine.session().tracks.is_empty());
}
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
let cmd_alt = egui::Modifiers { command: true, mac_cmd: true, alt: true, ..Default::default() };
for (arrow, expected) in [(egui::Key::ArrowDown, "ticks"), (egui::Key::ArrowUp, "samples")] {
dialog_frame(&ctx, &mut app, vec![key(arrow, cmd_alt)]);
assert_eq!(track_fields(&app).3, expected);
assert_eq!(track_fields(&app).0, 1);
assert_eq!(track_fields(&app).2, "audio");
}
}
/// Type changes update default names while preserving names entered by the user.
#[test]
fn new_tracks_type_changes_preserve_custom_names() {
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
tracks.rows[0].name = "Dialogue cinema".into();
}
let cmd = egui::Modifiers { command: true, mac_cmd: true, ..Default::default() };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
assert_eq!(track_fields(&app).4, "Dialogue cinema");
let mut kind = "audio".to_string();
let mut name = "Audio".to_string();
set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Instrument);
assert_eq!(name, "Inst");
name = "Foley".to_string();
set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Aux);
assert_eq!(name, "Foley");
}
/// Enter creates, while Escape cancels even when both keys arrive in one frame.
#[test]
fn new_tracks_enter_creates_and_escape_cancels_on_each_platform() {
for os in [egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
let (ctx, mut app) = tracks_dialog(os);
dialog_frame(&ctx, &mut app, vec![egui::Event::Text("3".into())]);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
assert!(app.dialogs.open.is_none());
assert_eq!(app.engine.session().tracks.len(), 3);
assert!(app.engine.session().markers.is_empty(), "Enter must not add a background marker");
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
dialog_frame(&ctx, &mut app, vec![]);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]);
assert!(app.dialogs.open.is_none());
assert_eq!(app.engine.session().tracks.len(), 3);
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
dialog_frame(&ctx, &mut app, vec![]);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
assert!(app.dialogs.open.is_none());
assert_eq!(app.engine.session().tracks.len(), 3, "Escape must cancel a simultaneous Enter");
}
}
/// Row shortcuts change the form without submitting when Enter arrives in the same frame.
#[test]
fn new_tracks_row_shortcuts_take_precedence_over_simultaneous_enter() {
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
let rows = egui::Modifiers { command: true, mac_cmd: true, shift: true, ..Default::default() };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
assert_eq!(tracks.rows.len(), 1, "the last row cannot be removed");
assert!(app.engine.session().tracks.is_empty());
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
assert_eq!(tracks.rows.len(), 2);
assert!(app.engine.session().tracks.is_empty());
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
assert_eq!(tracks.rows.len(), 1);
assert!(app.engine.session().tracks.is_empty());
}
/// Escape prevents a simultaneous Enter from starting a background bounce.
#[test]
fn simultaneous_enter_and_escape_does_not_start_a_bounce() {
let ctx = egui::Context::default();
crate::fonts::install(&ctx, true);
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default());
assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix"));
for _ in 0..3 {
dialog_frame(&ctx, &mut app, vec![]);
}
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
assert!(app.dialogs.open.is_none());
assert!(app.bounce.is_none());
}
/// Escape prevents a simultaneous Save from scheduling the deferred session action.
#[test]
fn simultaneous_save_and_escape_does_not_continue_after_saving() {
let ctx = egui::Context::default();
crate::fonts::install(&ctx, true);
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default());
let tracks = app.engine.session().tracks.len();
app.dialogs.open = Some(Dialog::SaveChanges { then: ("session.close".into(), json!({})) });
for _ in 0..3 {
dialog_frame(&ctx, &mut app, vec![]);
}
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
assert!(app.dialogs.open.is_none());
assert!(app.dialogs.after_save.is_none());
assert_eq!(app.engine.session().tracks.len(), tracks);
}
/// Windows and Linux chords cover every editable field and row action.
#[test]
fn new_tracks_windows_and_linux_chords_cover_every_field_and_row_action() {
for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
let (ctx, mut app) = tracks_dialog(os);
let ctrl = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl)]);
assert_eq!(track_fields(&app).1, "Stereo");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, ctrl)]);
assert_eq!(track_fields(&app).1, "Mono");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl)]);
assert_eq!(track_fields(&app).2, "aux");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl)]);
assert_eq!(track_fields(&app).2, "audio");
let ctrl_alt = egui::Modifiers { alt: true, ..ctrl };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl_alt)]);
assert_eq!(track_fields(&app).3, "ticks");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl_alt)]);
assert_eq!(track_fields(&app).3, "samples");
for alt in [false, true] {
let rows = egui::Modifiers { shift: true, alt, ..ctrl };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows)]);
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") };
assert_eq!(tracks.rows.len(), 2);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows)]);
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") };
assert_eq!(tracks.rows.len(), 1);
}
let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open else { panic!("missing dialog") };
tracks.rows[0].kind = "video".into();
tracks.rows[0].timebase = "ticks".into();
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl), key(egui::Key::ArrowUp, ctrl_alt)]);
assert_eq!(track_fields(&app).1, "Mono", "Video has no format control");
assert_eq!(track_fields(&app).3, "ticks", "Video has no timebase control");
}
}
/// A name that is not valid Unicode is not opened under a lossy rendering (another file): the
/// status line names its bytes.
+613
View File
@@ -0,0 +1,613 @@
//! One or more independent New Tracks rows, submitted as a single engine command.
use egui::{Event, Key, TextStyle, Ui, vec2};
use serde_json::{Value, json};
use soundcraft_model::{ChannelFormat, TrackKind};
const MAX_TRACKS: usize = 128;
const NAME_WIDTH: f32 = 260.0;
const ROW_BUTTON_WIDTH: f32 = 22.0;
#[derive(Debug, Clone)]
/// One independently configured row in the New Tracks dialog.
pub struct NewTrackRow {
key: u64,
pub count: u32,
pub format: String,
pub kind: String,
pub timebase: String,
pub name: String,
}
impl Default for NewTrackRow {
/// Starts one mono audio row with the program defaults.
fn default() -> Self {
Self { key: 0, count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
}
}
impl NewTrackRow {
/// Builds one `track.new` row without values belonging to hidden controls.
fn params(&self) -> Value {
let mut params = json!({"count": self.count, "kind": self.kind, "name": self.name});
if has_format(&self.kind) {
params["format"] = json!(self.format);
}
if has_timebase(&self.kind) {
params["timebase"] = json!(self.timebase);
}
params
}
}
#[derive(Debug, Clone)]
/// State for the multi-row New Tracks dialog.
pub struct NewTracks {
pub rows: Vec<NewTrackRow>,
active: usize,
next_key: u64,
focus_count: bool,
}
impl Default for NewTracks {
/// Starts the dialog with one row whose count field receives focus.
fn default() -> Self {
Self { rows: vec![NewTrackRow::default()], active: 0, next_key: 1, focus_count: true }
}
}
impl NewTracks {
/// Returns the row currently targeted by keyboard shortcuts.
pub fn active_row(&self) -> Option<&NewTrackRow> {
self.rows.get(self.active)
}
/// Inserts a default row after `index` and moves focus to its count.
fn insert_after(&mut self, index: usize) {
if self.rows.len() >= MAX_TRACKS || self.rows.get(index).is_none() {
return;
}
let Some(next) = self.next_key.checked_add(1) else { return };
self.rows.insert(index + 1, NewTrackRow { key: self.next_key, ..NewTrackRow::default() });
self.next_key = next;
self.active = index + 1;
self.focus_count = true;
}
/// Removes `index` while retaining at least one row and a valid active row.
fn remove(&mut self, index: usize) {
if self.rows.len() <= 1 || self.rows.get(index).is_none() {
return;
}
self.rows.remove(index);
if index == self.active {
self.active = index.min(self.rows.len().saturating_sub(1));
self.focus_count = true;
} else if index < self.active {
self.active = self.active.saturating_sub(1);
}
}
/// Handles the row, format, type and timebase shortcuts and reports a row action.
fn keys(&mut self, ctx: &egui::Context) -> bool {
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
let mut row_action = false;
ctx.input_mut(|i| {
i.events.retain(|event| {
let Event::Key { key, pressed: true, modifiers: m, .. } = event else { return true };
if !m.command {
return true;
}
if m.shift {
match key {
Key::ArrowDown => self.insert_after(self.active),
Key::ArrowUp => self.remove(self.active),
_ => return true,
}
row_action = true;
return false;
}
if m.alt && m.ctrl && mac {
return true;
}
let Some(row) = self.rows.get_mut(self.active) else { return true };
let forward = matches!(key, Key::ArrowRight | Key::ArrowDown);
match key {
Key::ArrowLeft | Key::ArrowRight if !m.alt => {
if has_format(&row.kind) {
let index = ChannelFormat::ALL.iter().position(|f| f.label() == row.format).unwrap_or(0);
if let Some(next) = ChannelFormat::ALL.get(cycle_index(index, ChannelFormat::ALL.len(), forward)) {
row.format = next.label().to_string();
}
}
}
Key::ArrowUp | Key::ArrowDown if m.alt => {
if has_timebase(&row.kind) {
row.timebase = if row.timebase == "ticks" { "samples" } else { "ticks" }.to_string();
}
}
Key::ArrowUp | Key::ArrowDown => {
let index = TrackKind::ALL.iter().position(|k| k.id() == row.kind).unwrap_or(0);
if let Some(next) = TrackKind::ALL.get(cycle_index(index, TrackKind::ALL.len(), forward)) {
set_new_track_kind(&mut row.kind, &mut row.name, *next);
}
}
_ => return true,
}
false
});
});
row_action
}
/// Draws every creation row and returns one atomic `track.new` request when submitted.
pub(crate) fn show(&mut self, ui: &mut Ui, enter: bool) -> Option<Value> {
let shortcut_row_action = self.keys(ui.ctx());
let widths = RowWidths::new(ui);
ui.set_min_width(widths.total(ui));
// The old row must not consume text on the first frame after adding a new one.
if self.focus_count && ui.is_enabled() && !ui.is_sizing_pass() {
ui.memory_mut(|m| {
if let Some(id) = m.focused() {
m.surrender_focus(id);
}
});
}
let mut insert = None;
let mut remove = None;
let len = self.rows.len();
egui::ScrollArea::vertical().id_salt("new_tracks_rows").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
for (index, row) in self.rows.iter_mut().enumerate() {
// Explicit row IDs keep editing state with the row when rows above it are removed.
let id = egui::Id::unique(("new_tracks_row", row.key));
let rect = egui::Rect::from_min_size(ui.available_rect_before_wrap().min, vec2(ui.available_width(), ui.spacing().interact_size.y));
ui.scope_builder(egui::UiBuilder::new().scope_id(id).max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
let label = ui.label("Create");
let counter = ui
.scope_builder(egui::UiBuilder::new().scope_id(id.with("count")), |ui| {
ui.spacing_mut().interact_size.x = widths.count;
if self.focus_count && index == self.active && ui.is_enabled() && !ui.is_sizing_pass() {
ui.memory_mut(|m| m.request_focus(ui.next_auto_id()));
self.focus_count = false;
}
ui.add(egui::DragValue::new(&mut row.count).speed(1.0).range(1..=128)).labelled_by(label.id)
})
.inner;
row.count = row.count.clamp(1, 128);
if counter.has_focus() || counter.clicked() {
self.active = index;
}
if counter.gained_focus() {
counter.scroll_to_me(Some(egui::Align::Center));
}
ui.label("new");
let format = if has_format(&row.kind) {
Some(
craft_ui::ComboBox::from_id_salt("nt_fmt")
.width(widths.format)
.selected_text(&row.format)
.show_ui(ui, |ui| {
for f in ChannelFormat::ALL {
ui.selectable_value(&mut row.format, f.label().to_string(), f.label());
}
})
.response
.accessible_name("Channel format"),
)
} else {
ui.allocate_space(vec2(widths.format, ui.spacing().interact_size.y));
None
};
let kind = craft_ui::ComboBox::from_id_salt("nt_kind")
.width(widths.kind)
.selected_text(TrackKind::from_id(&row.kind).map_or("Audio Track", |k| k.label()))
.show_ui(ui, |ui| {
for k in TrackKind::ALL {
if ui.selectable_label(row.kind == k.id(), k.label()).clicked() {
set_new_track_kind(&mut row.kind, &mut row.name, k);
}
}
})
.response
.accessible_name("Track kind");
let timebase = if has_timebase(&row.kind) {
ui.label("in");
Some(
craft_ui::ComboBox::from_id_salt("nt_tb")
.width(widths.timebase)
.selected_text(if row.timebase == "ticks" { "Ticks" } else { "Samples" })
.show_ui(ui, |ui| {
ui.selectable_value(&mut row.timebase, "samples".to_string(), "Samples");
ui.selectable_value(&mut row.timebase, "ticks".to_string(), "Ticks");
})
.response
.accessible_name("Timebase"),
)
} else {
ui.allocate_space(vec2(text_width(ui, "in"), ui.spacing().interact_size.y));
ui.allocate_space(vec2(widths.timebase, ui.spacing().interact_size.y));
None
};
let label = ui.label("Name:");
let name = crate::widgets::named_text_field(
ui.add(egui::TextEdit::singleline(&mut row.name).id(id.with("name")).desired_width(NAME_WIDTH)).labelled_by(label.id),
"Name:",
);
if format.iter().chain(timebase.iter()).chain([&kind, &name]).any(|r| r.clicked() || r.has_focus()) {
self.active = index;
}
let size = vec2(ROW_BUTTON_WIDTH, ui.spacing().interact_size.y);
if ui.add_enabled(len > 1, egui::Button::new("−").min_size(size)).on_hover_text("Remove this row").clicked() {
remove = Some(index);
}
if ui.add_enabled(len < MAX_TRACKS, egui::Button::new("+").min_size(size)).on_hover_text("Add a row below").clicked() {
insert = Some(index);
}
});
}
});
let row_action = shortcut_row_action || remove.is_some() || insert.is_some();
if let Some(index) = remove {
self.active = index;
self.remove(index);
ui.ctx().request_repaint();
} else if let Some(index) = insert {
self.insert_after(index);
ui.ctx().request_repaint();
}
let total = self.rows.iter().fold(0_u32, |total, row| total.saturating_add(row.count));
let can_create = !self.rows.is_empty() && total <= MAX_TRACKS as u32;
if !can_create {
ui.colored_label(ui.visuals().error_fg_color, "Create up to 128 tracks at a time.");
}
// Enter on a focused row button changes that row only; it must not also submit the dialog.
if crate::dialogs::buttons_enabled(ui, "Create", enter && !row_action, can_create) && !row_action {
Some(json!({"rows": self.rows.iter().map(NewTrackRow::params).collect::<Vec<_>>()}))
} else {
None
}
}
}
/// Measure all choices with the current UI font, rather than the selected label.
struct RowWidths {
count: f32,
format: f32,
kind: f32,
timebase: f32,
}
impl RowWidths {
/// Measures every possible choice so row columns stay fixed while values change.
fn new(ui: &Ui) -> Self {
Self {
count: (styled_text_width(ui, "128", &ui.style().drag_value_text_style) + 2.0 * ui.spacing().button_padding.x)
.ceil()
.max(ui.spacing().interact_size.x),
format: combo_width(ui, ChannelFormat::ALL.iter().map(|f| f.label())),
kind: combo_width(ui, TrackKind::ALL.iter().map(|k| k.label())),
timebase: combo_width(ui, ["Samples", "Ticks"].into_iter()),
}
}
/// Returns the complete width required by one row, including labels and buttons.
fn total(&self, ui: &Ui) -> f32 {
let labels: f32 = ["Create", "new", "in", "Name:"].iter().map(|s| text_width(ui, s)).sum();
labels + self.count + self.format + self.kind + self.timebase + NAME_WIDTH + 2.0 * ROW_BUTTON_WIDTH + 10.0 * ui.spacing().item_spacing.x + 8.0
}
}
/// Measures ordinary button text with the current theme and font scale.
fn text_width(ui: &Ui, text: &str) -> f32 {
styled_text_width(ui, text, &TextStyle::Button)
}
/// Measures one string using the supplied egui text style.
fn styled_text_width(ui: &Ui, text: &str, style: &TextStyle) -> f32 {
let font = style.resolve(ui.style());
ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), font, ui.visuals().text_color()).size().x)
}
/// Computes a stable ComboBox width from all labels it may display.
fn combo_width<'a>(ui: &Ui, labels: impl Iterator<Item = &'a str>) -> f32 {
let longest = labels.map(|s| text_width(ui, s)).fold(0.0, f32::max);
(longest + ui.spacing().icon_width + ui.spacing().icon_spacing + 2.0 * ui.spacing().button_padding.x + 2.0).ceil()
}
/// Changes kind and updates the default name without replacing a custom name.
pub(crate) fn set_new_track_kind(kind: &mut String, name: &mut String, next: TrackKind) {
if TrackKind::from_id(kind).is_some_and(|old| name == old.default_name()) {
*name = next.default_name().to_string();
}
*kind = next.id().to_string();
}
/// Reports whether a track kind exposes a channel-format control.
fn has_format(kind: &str) -> bool {
!matches!(TrackKind::from_id(kind), Some(TrackKind::Midi | TrackKind::Vca | TrackKind::Video))
}
/// Reports whether a track kind exposes a samples/ticks control.
fn has_timebase(kind: &str) -> bool {
!matches!(TrackKind::from_id(kind), Some(TrackKind::Video))
}
/// Moves one position through a circular choice list in either direction.
fn cycle_index(index: usize, len: usize, forward: bool) -> usize {
if forward {
if index.saturating_add(1) < len { index.saturating_add(1) } else { 0 }
} else {
index.checked_sub(1).unwrap_or(len.saturating_sub(1))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Services, SoundApp, dialogs::Dialog};
use egui::{Modifiers, accesskit::Role};
use egui_kittest::{Harness, kittest::Queryable};
/// Opens a settled New Tracks dialog in a macOS UI harness.
fn harness() -> Harness<'static, SoundApp> {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.ctx.set_os(egui::os::OperatingSystem::Mac);
h.run_steps(4);
h
}
/// Borrows the dialog model from a test harness.
fn tracks<'a>(h: &'a Harness<'_, SoundApp>) -> &'a NewTracks {
let Some(Dialog::NewTracks(tracks)) = &h.state().dialogs.open else { panic!("missing New Tracks dialog") };
tracks
}
/// Mutably borrows the dialog model from a test harness.
fn tracks_mut<'a>(h: &'a mut Harness<'_, SoundApp>) -> &'a mut NewTracks {
let Some(Dialog::NewTracks(tracks)) = &mut h.state_mut().dialogs.open else { panic!("missing New Tracks dialog") };
tracks
}
/// Sends one keyboard chord and lets a resized dialog settle.
fn chord(h: &mut Harness<SoundApp>, m: Modifiers, key: Key) {
h.key_press_modifiers(m, key);
// A centered window needs another frame to settle after its row count changes.
h.run_steps(4);
}
/// Every selectable label retains its column's maximum measured width.
#[test]
fn every_choice_keeps_the_same_field_widths_and_name_position() {
let mut h = harness();
let format = h.get_by_value("Mono").rect();
let kind = h.get_by_value("Audio Track").rect();
let timebase = h.get_by_value("Samples").rect();
let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
assert!(name.width() >= NAME_WIDTH);
for f in ChannelFormat::ALL {
tracks_mut(&mut h).rows[0].format = f.label().to_string();
h.run_steps(2);
assert_eq!(h.get_by_value(f.label()).rect().width(), format.width(), "{}", f.label());
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name);
}
for k in TrackKind::ALL {
tracks_mut(&mut h).rows[0].kind = k.id().to_string();
h.run_steps(2);
assert_eq!(h.get_by_value(k.label()).rect().width(), kind.width(), "{}", k.label());
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
}
tracks_mut(&mut h).rows[0].kind = "audio".to_string();
tracks_mut(&mut h).rows[0].timebase = "ticks".to_string();
tracks_mut(&mut h).rows[0].count = 128;
h.run_steps(2);
assert_eq!(h.get_by_value("Ticks").rect().width(), timebase.width());
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name);
h.ctx.global_style_mut(|s| s.drag_value_text_style = TextStyle::Heading);
tracks_mut(&mut h).rows[0].count = 1;
h.run_steps(4);
let larger_font_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
tracks_mut(&mut h).rows[0].count = 128;
h.run_steps(2);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), larger_font_name);
}
/// Hidden controls retain their layout space for kinds that do not use them.
#[test]
fn inapplicable_fields_are_hidden_without_moving_the_other_columns() {
let mut h = harness();
let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
let all_fields = h.get_all_by_role(Role::ComboBox).count();
for kind in [TrackKind::Midi, TrackKind::Vca] {
tracks_mut(&mut h).rows[0].kind = kind.id().to_string();
h.run_steps(2);
assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 1, "{}", kind.label());
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
}
tracks_mut(&mut h).rows[0].kind = TrackKind::Video.id().to_string();
h.run_steps(2);
assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 2);
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
}
/// Submitted rows omit controls that are hidden for their selected track kind.
#[test]
fn inapplicable_fields_are_omitted_from_row_parameters() {
let row = NewTrackRow {
count: 2,
format: "Stereo".into(),
kind: TrackKind::Midi.id().into(),
timebase: "ticks".into(),
name: "MIDI".into(),
..NewTrackRow::default()
};
assert_eq!(row.params(), json!({"count": 2, "kind": "midi", "timebase": "ticks", "name": "MIDI"}));
let row = NewTrackRow { kind: TrackKind::Vca.id().into(), name: "VCA".into(), ..row };
assert_eq!(row.params(), json!({"count": 2, "kind": "vca", "timebase": "ticks", "name": "VCA"}));
let row = NewTrackRow { kind: TrackKind::Video.id().into(), name: "Video".into(), ..row };
assert_eq!(row.params(), json!({"count": 2, "kind": "video", "name": "Video"}));
}
/// Both supported add/remove shortcut variants preserve independent row state.
#[test]
fn both_requested_row_chords_focus_the_new_count_and_preserve_other_rows() {
for alt in [false, true] {
let mut h = harness();
let row_chord = Modifiers { command: true, mac_cmd: true, ctrl: true, shift: true, alt };
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
chord(&mut h, row_chord, Key::ArrowDown);
assert_eq!(tracks(&h).rows.len(), 2);
assert_eq!(tracks(&h).active, 1);
h.event(Event::Text("7".into()));
h.run_steps(2);
assert_eq!(tracks(&h).rows[1].count, 7);
assert_eq!(tracks(&h).rows[0].count, 1);
assert_eq!(tracks(&h).rows[0].name, "Dialogue");
chord(&mut h, Modifiers { command: true, mac_cmd: true, ..Default::default() }, Key::ArrowDown);
assert_eq!(tracks(&h).rows[1].kind, "aux");
assert_eq!(tracks(&h).rows[0].kind, "audio");
chord(&mut h, row_chord, Key::ArrowUp);
assert_eq!(tracks(&h).rows.len(), 1);
assert_eq!(tracks(&h).rows[0].name, "Dialogue");
chord(&mut h, row_chord, Key::ArrowUp);
assert_eq!(tracks(&h).rows.len(), 1, "the last row cannot be removed");
assert!(h.state().engine.session().tracks.is_empty());
}
}
/// Each row's add and remove buttons act at that row's position.
#[test]
fn plus_and_minus_buttons_operate_on_their_own_row() {
let mut h = harness();
h.get_by_role_and_label(Role::Button, "+").click();
h.run_steps(4);
let names: Vec<_> = h.get_all_by_role_and_label(Role::TextInput, "Name:").map(|n| n.rect()).collect();
assert_eq!(names.len(), 2);
assert_eq!(names[0].min.x, names[1].min.x);
assert_eq!(names[0].width(), names[1].width());
assert!(names[1].min.y - names[0].min.y <= 32.0, "rows must stay compact: {names:?}");
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
tracks_mut(&mut h).rows[1].name = "Effects".into();
h.get_all_by_role_and_label(Role::Button, "+").next().unwrap().click();
h.run_steps(4);
assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), ["Dialogue", "Audio", "Effects"]);
h.get_all_by_role_and_label(Role::Button, "−").nth(1).unwrap().click();
h.run_steps(4);
assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), ["Dialogue", "Effects"]);
}
/// Enter on a focused row button changes the rows without also creating tracks.
#[test]
fn enter_on_row_buttons_does_not_submit_the_dialog() {
let mut h = harness();
h.get_by_role_and_label(Role::Button, "+").focus();
chord(&mut h, Modifiers::NONE, Key::Enter);
assert_eq!(tracks(&h).rows.len(), 2);
assert!(h.state().engine.session().tracks.is_empty());
h.get_all_by_role_and_label(Role::Button, "−").next().unwrap().focus();
chord(&mut h, Modifiers::NONE, Key::Enter);
assert_eq!(tracks(&h).rows.len(), 1);
assert!(h.state().engine.session().tracks.is_empty());
}
/// Enter on the focused Cancel button closes the dialog without creating tracks.
#[test]
fn enter_on_cancel_does_not_submit_the_dialog() {
let mut h = harness();
h.get_by_role_and_label(Role::Button, "Cancel").focus();
chord(&mut h, Modifiers::NONE, Key::Enter);
assert!(h.state().dialogs.open.is_none());
assert!(h.state().engine.session().tracks.is_empty());
}
/// Removing a preceding row does not transfer widget state to another row.
#[test]
fn removing_an_earlier_row_keeps_editing_state_with_the_surviving_row() {
let mut h = harness();
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
h.event(Event::Text("42".into()));
h.run_steps(2);
let focused = h.ctx.memory(|m| m.focused());
let key = tracks(&h).rows[1].key;
tracks_mut(&mut h).remove(0);
h.run_steps(2);
assert_eq!(tracks(&h).rows[0].key, key);
assert_eq!(tracks(&h).rows[0].count, 42);
assert_eq!(h.ctx.memory(|m| m.focused()), focused);
chord(&mut h, Modifiers::NONE, Key::ArrowUp);
assert_eq!(tracks(&h).rows[0].count, 43);
}
/// A clicked row receives later shortcuts without altering its custom name.
#[test]
fn clicking_a_different_row_targets_its_chords_and_keeps_its_custom_name() {
let mut h = harness();
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
h.get_all_by_role_and_label(Role::TextInput, "Name:").next().unwrap().click();
h.run_steps(2);
let cmd = Modifiers { command: true, mac_cmd: true, ..Default::default() };
chord(&mut h, cmd, Key::ArrowDown);
chord(&mut h, cmd, Key::ArrowRight);
chord(&mut h, Modifiers { alt: true, ..cmd }, Key::ArrowUp);
let rows = &tracks(&h).rows;
assert_eq!(
(&rows[0].kind, &rows[0].format, &rows[0].timebase, &rows[0].name),
(&"aux".to_string(), &"Stereo".to_string(), &"ticks".to_string(), &"Dialogue".to_string())
);
assert_eq!((&rows[1].kind, &rows[1].format, &rows[1].timebase), (&"audio".to_string(), &"Mono".to_string(), &"samples".to_string()));
assert_eq!((rows[0].count, rows[1].count), (1, 1));
}
/// Submitting several rows creates and undoes them as one engine action.
#[test]
fn create_submits_every_row_and_undo_removes_the_whole_creation() {
let mut h = harness();
tracks_mut(&mut h).rows[0].count = 2;
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
let row = &mut tracks_mut(&mut h).rows[1];
row.kind = "aux".into();
row.format = "Stereo".into();
row.name = "Reverb".into();
chord(&mut h, Modifiers::NONE, Key::Enter);
assert!(h.state().dialogs.open.is_none());
assert_eq!(
h.state().engine.session().tracks.iter().map(|t| t.kind).collect::<Vec<_>>(),
[TrackKind::Audio, TrackKind::Audio, TrackKind::Aux]
);
assert_eq!(h.state().engine.session().tracks[2].name, "Reverb");
assert!(h.state().engine.session().markers.is_empty());
assert!(h.state_mut().engine.undo());
assert!(h.state().engine.session().tracks.is_empty());
assert!(!h.state().engine.can_undo());
}
/// The aggregate 128-track limit disables creation while leaving cancellation usable.
#[test]
fn too_many_tracks_disable_create_but_leave_cancel_available() {
let mut h = harness();
tracks_mut(&mut h).rows[0].count = 128;
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
chord(&mut h, Modifiers::NONE, Key::Enter);
assert!(h.state().dialogs.open.is_some());
assert!(h.state().engine.session().tracks.is_empty());
h.get_by_role_and_label(Role::Button, "Cancel").click();
h.run_steps(2);
assert!(h.state().dialogs.open.is_none());
}
}
@@ -3,6 +3,17 @@ use super::{Arc, ClipId, Dialog, SoundApp, StripNames, Tokens, TrackId, Value, j
/// What Strip keeps on each track: the Strip Silence rectangles.
pub type StripRects = Arc<Vec<(TrackId, Vec<soundcraft_time::Range>)>>;
/// The Strip Silence values (threshold dB, min strip duration, clip start and end pads in ms) the
/// window opens with: the ones it last had, or these defaults before its first use.
#[derive(Debug, Clone, Copy)]
pub(super) struct StripValues(pub(super) [f64; 4]);
impl Default for StripValues {
fn default() -> Self {
Self([-48.0, 50.0, 5.0, 20.0])
}
}
/// The session revision and the Strip Silence values (as bits) that rectangles were found for.
type StripKey = (u64, [u64; 4]);
+7 -1
View File
@@ -167,14 +167,20 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
}
app.edit_layout.content_h = content_h;
let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y));
let grid = clips::GridLines::new(app.engine.session(), tl);
let grid_revision = app.engine.revision;
for (id, r) in app.edit_layout.rows.clone() {
let row = rect(r);
if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row));
child.set_clip_rect(clip_rect);
track_row(app, &mut child, TrackId(id), row, tl, &cols);
track_row(app, &mut child, TrackId(id), row, tl, &cols, &grid);
}
}
// Keep one consistent grid snapshot during this pass, then refresh after a row edits it.
if app.engine.revision != grid_revision {
ui.ctx().request_repaint();
}
// Below the tracks a click clears the selection and a double-click opens New Tracks.
let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max);
if empty.height() > 4.0 {
+33 -18
View File
@@ -25,26 +25,41 @@ pub(super) fn draw_strip_silence(painter: &egui::Painter, s: &Session, tl: Rect,
}
}
pub(super) fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) {
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.
let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false };
let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if bar_px > 6.0 {
let col = grid_veil(t.bar_line, t.playlist_bg);
for at in bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600) {
let x = x_of(s, tl, at);
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
/// Grid positions computed once for the current viewport and shared by all visible lanes.
#[derive(Default)]
pub(super) struct GridLines {
bars: Vec<f32>,
grid: Vec<f32>,
}
impl GridLines {
/// Collect and project visible lines before drawing any lanes in this frame.
pub(super) fn new(s: &Session, tl: Rect) -> Self {
let mut lines = Self::default();
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.
let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false };
let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if bar_px > 6.0 {
lines.bars = bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600).into_iter().map(|at| x_of(s, tl, at)).collect();
}
if s.edit.grid_lines {
let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if s.edit.grid_clips_markers || step_px > 7.0 {
let visible_columns = tl.width().ceil().clamp(1.0, 16_384.0) as usize;
lines.grid = s.grid_lines(view.start, view.end, visible_columns).into_iter().map(|at| x_of(s, tl, at)).collect();
}
}
lines
}
if s.edit.grid_lines {
let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if step_px > 7.0 {
let col = grid_veil(t.grid_line, t.playlist_bg);
for at in s.edit.grid.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 2000) {
let x = x_of(s, tl, at);
/// Draw the prepared lines on one lane, preserving bar and grid colors and stacking order.
pub(super) fn draw(&self, painter: &egui::Painter, lane: Rect) {
let t = Tokens::current();
for (positions, color) in [(&self.bars, t.bar_line), (&self.grid, t.grid_line)] {
let col = grid_veil(color, t.playlist_bg);
for &x in positions {
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
}
}
+23 -5
View File
@@ -64,7 +64,8 @@ pub(super) fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
let plan = drag_plan(ctx, &s, app.engine.revision, clips, (*from_track, *tt), *grab_at);
*delta = move_delta(&plan, &s, *from_track, at - *grab_at, ctx.input(|i| i.modifiers.command));
}
Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = drag_snap(&s, at, ctx.input(|i| i.modifiers.command)),
Some(Gesture::TrimStart { clip, to, .. }) => *to = trim_drag_snap(&s, *clip, at, true, ctx.input(|i| i.modifiers.command)),
Some(Gesture::TrimEnd { clip, to, .. }) => *to = trim_drag_snap(&s, *clip, at, false, ctx.input(|i| i.modifiers.command)),
Some(Gesture::MidiClip { to, .. }) => *to = at,
Some(Gesture::Fade { clip, fade_in, to, .. }) => {
if let Some(range) = s.find_clip(*clip).and_then(|(_, c)| fade_drag_range(c, *fade_in, at)) {
@@ -206,7 +207,18 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option<Pos2>) {
/// Grid snapping while dragging; holding Cmd/Ctrl swaps Grid and Slip.
pub(super) fn drag_snap(s: &Session, at: Samples, flip: bool) -> Samples {
if s.edit.edit_mode.is_grid() != flip { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
drag_snap_excluding(s, at, flip, &[])
}
/// Grid snapping that ignores boundaries belonging to clips changed by the gesture.
pub(super) fn drag_snap_excluding(s: &Session, at: Samples, flip: bool, excluded: &[ClipId]) -> Samples {
if s.edit.edit_mode.is_grid() != flip { s.snap_to_grid_excluding(at, excluded) } else { at }
}
/// Snap a trim while preserving the clip edge when the pointer returns to its origin.
pub(super) fn trim_drag_snap(s: &Session, clip: ClipId, at: Samples, start: bool, flip: bool) -> Samples {
let origin = s.find_clip(clip).map(|(_, clip)| if start { clip.start } else { clip.end() });
if origin == Some(at) { at } else { drag_snap_excluding(s, at, flip, &[clip]) }
}
/// What a clip drag works out once per target track rather than every frame: where the tracks go
@@ -216,6 +228,7 @@ pub(super) struct DragPlan {
pub(super) dest: Option<Vec<(TrackId, TrackId)>>,
pub(super) groups: Vec<(TrackId, Samples, Vec<Samples>)>,
pub(super) moving: Vec<(TrackId, Samples, Samples)>,
moving_ids: Vec<ClipId>,
grabbed: Option<Clip>,
}
@@ -246,10 +259,11 @@ pub(super) fn drag_plan(
};
let ids: HashSet<ClipId> = clips.iter().copied().collect();
let movable = |c: &&Clip| ids.contains(&c.id) && !ops::locked(c);
let moving = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect();
let moving: Vec<_> = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect();
let moving_ids: Vec<_> = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| c.id)).collect();
let mine: Vec<&Clip> = s.track(from).map(|t| t.clips().iter().filter(movable).collect()).unwrap_or_default();
let grabbed = mine.iter().rev().find(|c| c.range().contains(grab_at)).or_else(|| mine.iter().min_by_key(|c| c.start)).map(|c| (*c).clone());
let plan = Arc::new(DragPlan { dest, groups, moving, grabbed });
let plan = Arc::new(DragPlan { dest, groups, moving, moving_ids, grabbed });
ctx.data_mut(|d| d.insert_temp(id, (key, Arc::clone(&plan))));
plan
}
@@ -258,6 +272,10 @@ pub(super) fn drag_plan(
/// Shuffle lands the earliest clip bound for the target track on a slot there, as
/// `edit.move_clips` does; Grid snaps the grabbed clip.
fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: bool) -> Samples {
let track_changed = plan.dest.as_ref().is_some_and(|dest| ops::lands(dest, from) != from);
if raw == 0 && !track_changed {
return 0;
}
let mode = s.edit.edit_mode;
if mode == EditMode::Shuffle {
let to = plan.dest.as_ref().map_or(from, |d| ops::lands(d, from));
@@ -268,7 +286,7 @@ fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: b
} else if mode.is_grid() != flip
&& let Some(c) = &plan.grabbed
{
ops::grid_delta(s, c, raw, mode == EditMode::GridRelative)
ops::grid_delta(s, c, raw, mode == EditMode::GridRelative, &plan.moving_ids)
} else {
raw
}
+42 -6
View File
@@ -115,8 +115,18 @@ fn name_plate(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, select
track.name.clone()
};
let name_color = if selected { t.text_dark } else { t.text.lerp_to_gamma(t.text_dim, 0.4) };
ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER, &shown_name, bold(12.5), name_color);
let nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag());
let font = bold(12.5);
let galley = ui.painter().layout_no_wrap(shown_name.clone(), font.clone(), name_color);
let overflows = galley.size().x > name_r.width() - 3.0;
let (text_pos, align) =
if overflows { (pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER) } else { (name_r.center(), Align2::CENTER_CENTER) };
ui.painter().with_clip_rect(name_r).text(text_pos, align, &shown_name, font, name_color);
let mut nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag());
if overflows {
nresp = nresp.on_hover_ui(|ui| {
ui.label(&shown_name);
});
}
if nresp.double_clicked() {
app.dialogs.open_rename_track(id, &track.name);
} else if nresp.clicked() {
@@ -190,8 +200,9 @@ fn toggle_keys(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
let h = head.height();
let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h));
let m = &track.mixer;
if h >= 37.0 {
let (records, mixes) = (track.kind.has_playlist(), track.kind != TrackKind::Master);
let is_master = track.kind == TrackKind::Master;
if h >= 37.0 && !is_master {
let (records, mixes) = (track.kind.has_playlist(), true);
let implied = !m.solo && app.engine.session().tracks.iter().any(|t| t.mixer.solo);
let keys = [
(HeaderKey::Rec, m.record_arm, false, "mix.record_arm", records),
@@ -213,10 +224,13 @@ fn view_selector(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
let id = track.id;
let h = head.height();
let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h));
if h >= 58.0 {
let is_master = track.kind == TrackKind::Master;
let view_y = if is_master { 20.0 } else { 39.0 };
let view_min_h = if is_master { 37.0 } else { 58.0 };
if h >= view_min_h {
let vresp = header_selector(
ui,
at(17.0, 39.0, 105.0, 17.0),
at(17.0, view_y, 105.0, 17.0),
ui.scope_id().with(("view", id.0)),
&view_label(&track.view),
t.text,
@@ -225,10 +239,32 @@ fn view_selector(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
);
egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track));
}
if is_master && h >= 58.0 {
let mode = track.mixer.automation_mode;
let col = match mode {
soundcraft_model::AutomationMode::Read => t.auto_read,
soundcraft_model::AutomationMode::Write => t.auto_write,
soundcraft_model::AutomationMode::Touch | soundcraft_model::AutomationMode::TouchLatch => t.auto_touch,
soundcraft_model::AutomationMode::Latch => t.auto_latch,
_ => t.text_dim,
};
let mode_name = format!("Automation mode for {}", track.name);
let aresp = header_selector(ui, at(17.0, 39.0, 105.0, 17.0), ui.scope_id().with(("auto_mode", id.0)), mode.label(), col, true, &mode_name);
egui::Popup::menu(&aresp).show(|ui| {
for m in soundcraft_model::AutomationMode::ALL {
if ui.selectable_label(m == mode, m.label()).clicked() {
let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()}));
}
}
});
}
}
/// Freeze, voice and automation mode.
fn voice_row(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
if track.kind == TrackKind::Master {
return;
}
let t = Tokens::current();
let id = track.id;
let h = head.height();
+2 -1
View File
@@ -9,9 +9,10 @@ use super::{
// ---- interaction -----------------------------------------------------------------------------
/// Apply snapping when the current edit mode or Snap to Grid option enables it.
pub(super) fn snap(s: &Session, at: Samples) -> Samples {
let on = s.edit.edit_mode.is_grid() || s.edit.flag(ops::SNAP_TO_GRID);
if on { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
if on { s.snap_to_grid(at) } else { at }
}
pub(super) fn clip_at(track: &Track, at: Samples) -> Option<&Clip> {
+2 -1
View File
@@ -222,7 +222,8 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect,
} else if resp.clicked()
&& let Some(m) = hit
{
let _ = app.run("markers.recall", json!({"number": m.number}));
let command = if ui.input(|i| i.modifiers.alt) { "markers.delete" } else { "markers.recall" };
let _ = app.run(command, json!({"number": m.number}));
}
}
}
+66
View File
@@ -2,6 +2,25 @@ use super::*;
use egui::accesskit::Role;
use egui_kittest::{Harness, kittest::Queryable};
/// A trim cannot snap to the clip's own old boundary in Clips/Markers mode.
#[test]
fn clips_markers_trim_ignores_the_trimmed_clip() {
let mut s = Session::default();
let track = s.add_track(TrackKind::Midi, soundcraft_model::ChannelFormat::Mono, None);
let clip = Clip::midi(s.new_clip_id(), "Notes", 1_000, 1_000, soundcraft_midi::Sequence::default());
let clip_id = clip.id;
s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(clip);
s.add_marker("Inside", soundcraft_model::MarkerKind::Marker, 1_500, 1_500);
s.add_marker("Target", soundcraft_model::MarkerKind::Marker, 2_500, 2_500);
s.edit.edit_mode = soundcraft_model::EditMode::Grid;
s.edit.grid_clips_markers = true;
assert_eq!(super::drag::drag_snap(&s, 2_100, false), 2_000);
assert_eq!(super::drag::drag_snap_excluding(&s, 2_100, false, &[clip_id]), 2_500);
assert_eq!(super::drag::trim_drag_snap(&s, clip_id, 1_000, true, false), 1_000);
assert_eq!(super::drag::trim_drag_snap(&s, clip_id, 2_000, false, false), 2_000);
}
/// The painted header selectors are interactive controls; their accessible name is the field
/// they edit, not the short value text ("Read", "Waveform") painted inside them.
#[test]
@@ -1508,3 +1527,50 @@ fn alt_click_on_a_breakpoint_deletes_it() {
assert_eq!(h.state().engine.session().track(track).unwrap().clips(), clips.as_slice(), "{tool}");
}
}
#[test]
fn alt_click_on_a_marker_deletes_it() {
let mut engine = soundcraft_engine::demo::demo_engine();
engine.session_mut().edit.rulers = vec!["markers".into()];
engine.execute("markers.add", &json!({"at": 48_000, "name": "Alt-delete me"})).unwrap();
let mut h = harness_with(engine);
let s = h.state().engine.session_arc();
let marker = s.markers.iter().find(|m| m.name == "Alt-delete me").unwrap();
let at = pos2(x_of(&s, timeline(&h), marker.start), timeline(&h).min.y - 11.5);
let before = s.markers.len();
click_with(&mut h, at, egui::Modifiers::ALT);
assert_eq!(h.state().engine.session().markers.len(), before - 1);
assert!(!h.state().engine.session().markers.iter().any(|m| m.name == "Alt-delete me"));
}
#[test]
fn short_track_name_is_centered_and_long_name_is_left_aligned() {
let mut h = harness(2);
let s = h.state().engine.session_arc();
let first = s.tracks[0].id.0;
let second = s.tracks[1].id.0;
h.state_mut().run("track.rename", json!({"track": first, "name": "Vocal"})).unwrap();
h.state_mut().run("track.rename", json!({"track": second, "name": "A Very Long Track Name That Exceeds Header Width"})).unwrap();
h.step();
let r0 = row(&h, 0);
let r1 = row(&h, 1);
let first_text = h.output().shapes.iter().find_map(|s| match &s.shape {
Shape::Text(t) if t.galley.text() == "Vocal" && s.clip_rect.intersects(r0) => Some((t.pos, t.galley.size())),
_ => None,
});
let long_text = h.output().shapes.iter().find_map(|s| match &s.shape {
Shape::Text(t) if t.galley.text() == "A Very Long Track Name That Exceeds Header Width" && s.clip_rect.intersects(r1) => Some(t.pos),
_ => None,
});
let (first_pos, first_size) = first_text.expect("short track name should be rendered in edit header");
let long_pos = long_text.expect("long track name should be rendered in edit header");
assert_eq!(long_pos.x, r1.min.x + 17.5);
let first_center_x = first_pos.x + first_size.x * 0.5;
assert!((first_center_x - (r0.min.x + 64.5)).abs() <= 0.1, "short name should be centered (center was at {first_center_x})");
}
+5 -4
View File
@@ -1,5 +1,5 @@
use super::automation::draw_automation;
use super::clips::{ClipLane, draw_clip, draw_live_take, draw_strip_silence, grid_lines};
use super::clips::{ClipLane, GridLines, draw_clip, draw_live_take, draw_strip_silence};
use super::header::{header_column, track_header};
use super::input::lane_interaction;
use super::layout::{Column, HEADER_W, LANE_H, STRIP_W, lane_rect, sample_at, x_of};
@@ -58,7 +58,8 @@ pub(crate) fn click_track(app: &mut SoundApp, id: TrackId, mods: egui::Modifiers
// ---- track rows ------------------------------------------------------------------------------
pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column]) {
/// Draw one track with grid positions shared by all lanes in the current frame.
pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column], grid: &GridLines) {
let t = Tokens::current();
let s = app.engine.session_arc();
let Some(track) = s.track(id) else { return };
@@ -84,7 +85,7 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect,
let s = app.engine.session_arc();
// Shown grid lines go over the clips; otherwise the bar lines stay behind them.
if !s.edit.grid_lines {
grid_lines(&painter, &s, tl, lane);
grid.draw(&painter, lane);
}
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
let auto_view = AutoParam::parse(&track.view);
@@ -98,7 +99,7 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect,
draw_live_take(app, &painter, &s, track, tl, lane);
crate::video_track::draw_lane(app, &painter, &s, track, tl, lane);
if s.edit.grid_lines {
grid_lines(&painter, &s, tl, lane);
grid.draw(&painter, lane);
}
if let Some(p) = &auto_view {
draw_automation(&painter, &s, track, p, tl, lane);
+13
View File
@@ -176,6 +176,19 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) {
painter.line_segment([p(r, x, 0.5 - h), p(r, x, 0.5 + h)], s);
}
}
"link_track" => {
// A link chain below two track rows, so the track link reads apart from the timeline link's plain chain.
painter.line_segment([p(r, 0.12, 0.18), p(r, 0.88, 0.18)], s);
painter.line_segment([p(r, 0.12, 0.32), p(r, 0.88, 0.32)], s);
painter.circle_stroke(p(r, 0.38, 0.72), r.width() * 0.16, s);
painter.circle_stroke(p(r, 0.62, 0.72), r.width() * 0.16, s);
}
"insertion_follows" => {
// The insertion line, a lead, and the playhead arrow it follows.
painter.line_segment([p(r, 0.3, 0.14), p(r, 0.3, 0.86)], s);
painter.line_segment([p(r, 0.42, 0.5), p(r, 0.62, 0.5)], s);
painter.add(Shape::convex_polygon(vec![p(r, 0.84, 0.5), p(r, 0.64, 0.34), p(r, 0.64, 0.66)], c, Stroke::NONE));
}
_ => {
painter.rect_stroke(r.shrink(r.width() * 0.25), 1.0, s, egui::StrokeKind::Middle);
}
+14
View File
@@ -27,6 +27,7 @@ pub mod menus;
pub mod mix_window;
pub mod palette;
pub mod panels;
pub mod preferences;
pub mod shortcuts;
pub mod theme;
pub mod toolbar;
@@ -114,6 +115,13 @@ pub struct UiState {
pub show_workspace: bool,
pub show_configurations: bool,
pub show_playback_engine: bool,
/// Setup › Preferences. The selected page is kept per machine; the search and staged values
/// live in [`preferences::PreferencesState`] and are discarded when the window closes.
pub show_preferences: bool,
pub preferences_page: preferences::PreferencesPage,
pub preferences_window_pos: Option<[f32; 2]>,
pub reduce_visual_effects: preferences::ReduceVisualEffects,
pub undo_levels: usize,
/// The audio device choice (Setup › Playback Engine), kept with the preferences: per machine.
pub audio: soundcraft_playback::AudioConfig,
/// The Mix window lives in its own native window (Window › Mix in Separate Window); the main
@@ -182,6 +190,11 @@ impl Default for UiState {
show_workspace: false,
show_configurations: false,
show_playback_engine: false,
show_preferences: false,
preferences_page: preferences::PreferencesPage::default(),
preferences_window_pos: None,
reduce_visual_effects: preferences::ReduceVisualEffects::default(),
undo_levels: 64,
audio: soundcraft_playback::AudioConfig::default(),
mix_separate: false,
show_io_setup: false,
@@ -302,6 +315,7 @@ pub struct SoundApp {
pub ops: ops_windows::OpsState,
pub extra: extra_windows::ExtraState,
pub palette: palette::PaletteState,
pub preferences: preferences::PreferencesState,
/// Bring the separate Mix window to the front on the next frame.
pub(crate) focus_mix: bool,
/// Scroll Into View: the track each window brings to its top (Edit) or left (Mix) when it
+150 -45
View File
@@ -75,6 +75,8 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("ui.bounce_dialog", "Bounce Mix…", "", None),
("ui.set", "Set UI State", "", None),
("ui.theme", "Appearance", "", None),
("ui.reduce_visual_effects", "Reduce Visual Effects", "", None),
("ui.undo_levels", "Levels of Undo", "", None),
];
/// Extra catalog mappings handled by the UI layer.
@@ -225,56 +227,65 @@ pub fn tree() -> Vec<MenuNode> {
pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::current();
let menu_entries_enabled = !app.ui.show_preferences;
let preferences_enabled = preferences_menu_enabled(app);
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() {
app.ui.show_about = true;
}
if ui.button("Check for Updates…").clicked() {
let _ = app.run("app.checkForUpdates", json!({}));
}
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 _ = crate::dialogs::run_or_ask(app, "app.quit", json!({}));
}
ui.add_enabled_ui(menu_entries_enabled, |ui| {
if ui.button("About SoundCraft").clicked() {
app.ui.show_about = true;
}
if ui.add_enabled(preferences_enabled, egui::Button::new("Preferences...")).clicked() {
let _ = app.run("setup.preferences", json!({"value": true}));
}
if ui.button("Check for Updates…").clicked() {
let _ = app.run("app.checkForUpdates", json!({}));
}
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 _ = crate::dialogs::run_or_ask(app, "app.quit", json!({}));
}
});
});
let extra = ui_aliases();
for root in tree() {
ui.menu_button(&root.label, |ui| {
for c in &root.children {
menu_node(app, ui, c, &extra);
if c.path == "File > Open" {
crate::recent::menu(app, ui);
ui.add_enabled_ui(menu_entries_enabled, |ui| {
for c in &root.children {
menu_node(app, ui, c, &extra);
if c.path == "File > Open" {
crate::recent::menu(app, ui);
}
if c.path == "Track > New..." {
instrument_menu(app, ui);
}
}
if c.path == "Track > New..." {
instrument_menu(app, ui);
if root.label == "AudioSuite" {
ui.separator();
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
}
}
if root.label == "AudioSuite" {
ui.separator();
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
}
if root.label == "Window" {
ui.separator();
let mut separate = app.ui.mix_separate;
if ui
.checkbox(&mut separate, "Mix in Separate Window")
.on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
.changed()
{
let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
ui.close();
if root.label == "Window" {
ui.separator();
let mut separate = app.ui.mix_separate;
if ui
.checkbox(&mut separate, "Mix in Separate Window")
.on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
.changed()
{
let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
ui.close();
}
}
}
});
});
}
let title = format!(
@@ -338,18 +349,31 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option<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") || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") =>
{
true
let enabled = if app.ui.show_preferences {
false
} else {
match id {
Some("setup.preferences") => preferences_menu_enabled(app),
Some(i)
if i.starts_with("window.")
|| i.starts_with("view.mix_section")
|| matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") =>
{
true
}
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok() && !app.engine.blocked_while_recording(i)),
None => false,
}
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok() && !app.engine.blocked_while_recording(i)),
None => false,
};
(enabled, checked_state(app, path, id))
}
/// Preferences cannot replace audio resources while transport is active or another Preferences
/// instance is already modal.
fn preferences_menu_enabled(app: &SoundApp) -> bool {
!app.ui.show_preferences && !app.is_playing() && !app.engine.transport.recording
}
fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool {
let e = &app.engine.session().edit;
match id {
@@ -469,6 +493,10 @@ pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option<String> {
/// Menu-style invocation: may open a dialog.
pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
if id == "setup.preferences" {
let _ = app.run(id, json!({"value": true}));
return;
}
if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path)
&& w.starts_with("window.")
{
@@ -587,6 +615,15 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
"window.midi_ops" => toggle(&mut app.ui.show_midi_ops),
"window.rtp" => toggle(&mut app.ui.show_rtp),
"window.playback_engine" => toggle(&mut app.ui.show_playback_engine),
// The engine catalog already exposes this command. Handling it here keeps the menu,
// control channel and scripted callers on the same public command id.
"setup.preferences" => {
let result = toggle(&mut app.ui.show_preferences);
if !app.ui.show_preferences {
app.preferences.discard_draft();
}
result
}
"window.io_setup" => toggle(&mut app.ui.show_io_setup),
"window.shortcuts" => toggle(&mut app.ui.show_shortcuts),
"window.color_palette" => toggle(&mut app.ui.show_color_palette),
@@ -640,6 +677,8 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
app.ui.show_undo_history = false;
app.ui.show_session_info = false;
app.ui.show_audio_health = false;
app.ui.show_preferences = false;
app.preferences.discard_draft();
app.ui.show_automation = false;
app.ui.plugin_windows.clear();
app.ui.audiosuite = None;
@@ -694,6 +733,23 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
app.ui.theme = mode;
json!({"mode": mode.id()})
}
"ui.reduce_visual_effects" => {
let Some(mode) = p.get("mode").and_then(Value::as_str) else {
return Some(Ok(json!({"mode": app.ui.reduce_visual_effects.id()})));
};
let Some(mode) = crate::preferences::ReduceVisualEffects::parse(mode) else {
return Some(Err(format!("unknown visual-effects mode `{mode}` (never, playback or always)")));
};
app.ui.reduce_visual_effects = mode;
json!({"mode": mode.id()})
}
"ui.undo_levels" => {
if let Some(levels) = p.get("levels").and_then(Value::as_u64) {
app.ui.undo_levels = usize::try_from(levels).unwrap_or(64).clamp(1, 64);
app.engine.set_history_limit(app.ui.undo_levels);
}
json!({"levels": app.ui.undo_levels})
}
"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()) {
@@ -706,8 +762,14 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
if let Err(error) = crate::docking::validate(&ns.docking).and_then(|()| crate::docking::validate_mix(&ns.mix_docking)) {
return Some(Err(error));
}
let closed_preferences = app.ui.show_preferences && !ns.show_preferences;
crate::docking::cancel_replaced_state(app, &ns);
app.ui = ns;
app.ui.undo_levels = app.ui.undo_levels.clamp(1, 64);
app.engine.set_history_limit(app.ui.undo_levels);
if closed_preferences {
app.preferences.discard_draft();
}
json!({"ui": app.ui})
}
Err(e) => return Some(Err(format!("bad ui state: {e}"))),
@@ -731,6 +793,49 @@ mod tests {
use super::*;
use crate::{Services, SoundApp};
/// Preferences menu entries follow transport safety and the modal window locks every menu.
#[test]
fn preferences_menu_state_tracks_transport_and_modality() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
assert!(item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);
app.engine.transport.playing = true;
assert!(!item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);
app.engine.transport.playing = false;
app.engine.transport.recording = true;
assert!(!item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);
app.engine.transport.recording = false;
app.ui.show_preferences = true;
assert!(!item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);
assert!(!item_state(&app, "Window > About", Some("window.about")).0);
assert!(!item_state(&app, "File > Save Session", Some("session.save")).0);
}
/// Re-selecting Preferences through either menu keeps its modal window open.
#[test]
fn preferences_menu_always_opens_instead_of_toggling() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
invoke_menu(&mut app, "setup.preferences", "Setup > Preferences...");
assert!(app.ui.show_preferences);
invoke_menu(&mut app, "setup.preferences", "Setup > Preferences...");
assert!(app.ui.show_preferences);
}
/// Generic UI-state updates clamp and immediately synchronize the engine history limit.
#[test]
fn ui_set_synchronizes_undo_levels_immediately() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let result = app.run("ui.set", json!({"undo_levels": 0})).unwrap();
assert_eq!(result["ui"]["undo_levels"], 1);
assert_eq!((app.ui.undo_levels, app.engine.history_limit), (1, 1));
let result = app.run("ui.set", json!({"undo_levels": 100})).unwrap();
assert_eq!(result["ui"]["undo_levels"], 64);
assert_eq!((app.ui.undo_levels, app.engine.history_limit), (64, 64));
}
#[test]
fn mix_view_aliases_are_allocated_once() {
let first = ui_aliases();
+22 -1
View File
@@ -11,7 +11,12 @@ pub(crate) fn route_name(app: &SoundApp, r: &Route) -> String {
}
pub(crate) fn input_name(app: &SoundApp, track: &Track) -> String {
if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) }
if track.kind.has_playlist() && track.mixer.input == Route::None {
let channels = track.format.channels();
if channels == 1 { "In 1".to_string() } else { format!("In 1-{channels}") }
} else {
route_name(app, &track.mixer.input)
}
}
/// The input or output menu under `resp`. It closes only on a click outside, so its bus search
@@ -59,3 +64,19 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
ui.close();
}
}
#[cfg(test)]
mod tests {
use super::input_name;
use crate::{Services, SoundApp};
use serde_json::json;
#[test]
fn stereo_track_defaults_to_a_stereo_input_pair() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
app.run("track.new", json!({"format": "stereo"})).unwrap();
let track = &app.engine.session().tracks[0];
assert_eq!(input_name(&app, track), "In 1-2");
}
}
+90 -4
View File
@@ -388,8 +388,17 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac
},
);
let Some((picked, footer)) = shown else { return };
if let Some(bus) = picked {
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus}));
// As in Pro Tools, a new send opens its Send window (moving it from any other send), so its
// fader is on screen right away. A send that only changes destination leaves the window alone.
let show_new_send = |app: &mut SoundApp| {
if !has_send {
app.ui.send_window = Some((id, slot));
}
};
if let Some(bus) = picked
&& app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus})).is_ok()
{
show_new_send(app);
}
match footer {
Some(SendFooter::NewBus) => {
@@ -399,8 +408,10 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac
{
n += 1;
}
if app.run("mix.new_bus", json!({"name": format!("Bus {n}")})).is_ok() {
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
if app.run("mix.new_bus", json!({"name": format!("Bus {n}")})).is_ok()
&& app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})).is_ok()
{
show_new_send(app);
}
}
Some(SendFooter::RemoveSend) => {
@@ -464,4 +475,79 @@ mod tests {
assert_eq!(field(&h), search, "the search field stays put");
assert!((footer(&h).top() - full.top()).abs() < 1.0, "cleared, the footer is back under the full list: {:?} vs {full:?}", footer(&h));
}
/// Kick's send A slot in a test harness, with buses "Aux" and "Other"; with `existing_send`, the
/// slot already sends to "Other".
fn kick_send_harness(existing_send: bool) -> (Harness<'static, (SoundApp, TrackId)>, TrackId) {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.run("mix.new_bus", json!({"name": "Aux"})).unwrap();
app.run("mix.new_bus", json!({"name": "Other"})).unwrap();
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
if existing_send {
app.run("mix.send", json!({"track": kick.0, "slot": 0, "bus": "Other"})).unwrap();
}
let mut ready = false;
let h = Harness::builder().with_size(vec2(600.0, 800.0)).build_ui_state(
move |ui, (app, kick): &mut (SoundApp, TrackId)| {
if !ready {
crate::fonts::install(ui.ctx(), true);
crate::theme::apply(ui.ctx(), false);
ready = true;
return;
}
let track = app.engine.session().track(*kick).cloned().unwrap();
let r = Rect::from_min_size(ui.max_rect().min + vec2(20.0, 20.0), vec2(120.0, 24.0));
send_slot(app, ui, &track, 0, r);
},
(app, kick),
);
(h, kick)
}
/// Creating a send opens its Send window, so the new send's fader is on screen right away (#1269).
#[test]
fn creating_a_send_opens_its_send_window() {
let _palette = crate::theme::lock_test_palette();
let (mut h, kick) = kick_send_harness(false);
h.run();
h.get_by_label("Send A for Kick").click();
h.run();
h.get_by_label("Aux").click();
h.run();
let (app, _) = h.state();
let send = app.engine.session().track(kick).and_then(|t| t.mixer.sends.first().cloned().flatten());
assert!(send.is_some(), "the send was created");
assert_eq!(app.ui.send_window, Some((kick, 0)), "the new send's window is open");
}
/// So does a send made with "new bus...".
#[test]
fn a_send_to_a_new_bus_opens_its_send_window() {
let _palette = crate::theme::lock_test_palette();
let (mut h, kick) = kick_send_harness(false);
h.run();
h.get_by_label("Send A for Kick").click();
h.run();
h.get_by_label("new bus...").click();
h.run();
let (app, _) = h.state();
assert!(app.engine.session().track(kick).and_then(|t| t.mixer.sends.first().cloned().flatten()).is_some(), "the send was created");
assert_eq!(app.ui.send_window, Some((kick, 0)), "the new send's window is open");
}
/// Moving an existing send to another bus keeps the Send window as it was.
#[test]
fn changing_a_sends_destination_leaves_the_send_window_alone() {
let _palette = crate::theme::lock_test_palette();
let (mut h, kick) = kick_send_harness(true);
h.run();
h.get_by_label("Send A for Kick").click();
h.run();
h.get_by_label("Aux").click();
h.run();
let (app, _) = h.state();
let target = app.engine.session().track(kick).and_then(|t| t.mixer.sends.first().cloned().flatten()).map(|s| route_name(app, &s.target));
assert_eq!(target.as_deref(), Some("Aux"), "the send moved");
assert_eq!(app.ui.send_window, None, "no window opened");
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ pub(super) fn about(app: &mut SoundApp, ctx: &egui::Context) {
return;
}
let tab_id = egui::Id::unique("about_tab");
egui::Window::new("About SoundCraft").open(&mut open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| {
egui::Window::new("About SoundCraft").open(&mut open).default_size(vec2(820.0, 420.0)).collapsible(false).show(ctx, |ui| {
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
ui.horizontal(|ui| {
for (i, l) in (0u8..).zip(["About", "Contributors", "Models"]) {
+18 -5
View File
@@ -16,7 +16,6 @@ pub struct ExtraState {
pub show_ui_customization: bool,
pub auto_update_config: bool,
pub ui_scale: f32,
pub reduce_motion: bool,
pub tooltips: bool,
last_auto_update: f64,
applied: Option<(f32, bool)>,
@@ -29,7 +28,6 @@ impl Default for ExtraState {
show_ui_customization: false,
auto_update_config: false,
ui_scale: 1.0,
reduce_motion: false,
tooltips: true,
last_auto_update: 0.0,
applied: None,
@@ -59,7 +57,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
fn apply_customization(app: &mut SoundApp, ctx: &egui::Context) {
let want = (app.extra.ui_scale.clamp(0.75, 1.5), app.extra.reduce_motion);
let reduce = match app.ui.reduce_visual_effects {
crate::preferences::ReduceVisualEffects::Never => false,
crate::preferences::ReduceVisualEffects::Playback => app.is_playing(),
crate::preferences::ReduceVisualEffects::Always => true,
};
let want = (app.extra.ui_scale.clamp(0.75, 1.5), reduce);
// Rescaling mid-drag would move the Interface scale slider out from under the pointer.
if app.extra.applied == Some(want) || ctx.input(|i| i.pointer.any_down()) {
return;
@@ -163,7 +166,17 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
.custom_formatter(|v, _| format!("{:.0} %", v * 100.0))
.text("Interface scale"),
);
crate::widgets::checkbox(ui, &mut app.extra.reduce_motion, "Reduce motion");
let mut chosen_effects = None;
craft_ui::ComboBox::from_label("Reduce visual effects").selected_text(app.ui.reduce_visual_effects.label()).show_ui(ui, |ui| {
for mode in crate::preferences::ReduceVisualEffects::ALL {
if ui.selectable_label(app.ui.reduce_visual_effects == mode, mode.label()).clicked() {
chosen_effects = Some(mode);
}
}
});
if let Some(mode) = chosen_effects {
let _ = app.run("ui.reduce_visual_effects", json!({"mode": mode.id()}));
}
let mut chosen = None;
craft_ui::ComboBox::from_label("Theme").selected_text(app.ui.theme.label()).show_ui(ui, |ui| {
for mode in crate::theme::ThemeMode::ALL {
@@ -179,7 +192,7 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
ui.add_space(4.0);
if ui.button("Reset").clicked() {
app.extra.ui_scale = 1.0;
app.extra.reduce_motion = false;
let _ = app.run("ui.reduce_visual_effects", json!({"mode": "never"}));
let _ = app.run("ui.theme", json!({"mode": crate::theme::ThemeMode::default().id()}));
}
},
+24 -4
View File
@@ -46,9 +46,28 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
if crate::widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(plus)), plus.size(), "plus", false, &tip).clicked() {
let _ = app.dialogs.open_for_command(&app.engine, "track.new");
}
let tracks: Vec<(u64, String, bool, [u8; 3], bool)> = {
let tracks: Vec<(u64, String, bool, [u8; 3], bool, usize)> = {
let s = app.engine.session();
s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect()
s.tracks
.iter()
.map(|x| {
let mut current = x.folder;
let mut depth = 0;
let mut seen = Vec::new();
while let Some(folder_id) = current {
if seen.contains(&folder_id) {
break;
}
seen.push(folder_id);
let Some(folder) = s.track(folder_id) else {
break;
};
depth += 1;
current = folder.folder;
}
(x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id), depth)
})
.collect()
};
let hidden = egui::scroll_area::ScrollBarVisibility::AlwaysHidden;
let lists = with_pending_offset(egui::ScrollArea::vertical().id_salt("tracks_scroll"), ui, "tracks_scroll")
@@ -56,7 +75,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
.auto_shrink([false, false])
.scroll_bar_visibility(hidden);
let out = lists.show(ui, |ui| {
for (id, name, shown, color, sel) in tracks {
for (id, name, shown, color, sel, depth) in tracks {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, &name));
if sel {
@@ -70,7 +89,8 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
}
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color));
ui.painter().with_clip_rect(r).text(
pos2(r.min.x + 30.0, r.center().y),
// Indent stops growing at eight levels so a deep name keeps room in the column.
pos2(r.min.x + 30.0 + depth.min(8) as f32 * 10.0, r.center().y),
Align2::LEFT_CENTER,
&name,
regular(11.5),
+7 -1
View File
@@ -408,7 +408,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_configurations = open;
}
/// Restore a saved window configuration, keeping the configuration list and the appearance.
/// Restore a window layout while retaining current application preferences and any open draft.
/// A configuration saved before the current panel layouts gets the default layouts.
pub fn recall_configuration(app: &mut SoundApp, saved: &Value) -> Result<(), String> {
let mut saved = saved.clone();
@@ -417,8 +417,14 @@ pub fn recall_configuration(app: &mut SoundApp, saved: &Value) -> Result<(), Str
crate::docking::validate(&st.docking).and_then(|()| crate::docking::validate_mix(&st.mix_docking))?;
st.configurations = app.ui.configurations.clone();
st.theme = app.ui.theme;
st.show_preferences = app.ui.show_preferences;
st.preferences_page = app.ui.preferences_page;
st.preferences_window_pos = app.ui.preferences_window_pos;
st.reduce_visual_effects = app.ui.reduce_visual_effects;
st.undo_levels = app.ui.undo_levels.clamp(1, 64);
st.show_configurations = true;
crate::docking::cancel_replaced_state(app, &st);
app.engine.set_history_limit(st.undo_levels);
app.ui = st;
Ok(())
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -59,7 +59,7 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
// Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() || app.ui.show_preferences {
return;
}
// A scripted press is identified by its exact position in this pass's events, not by matching
+2
View File
@@ -457,6 +457,8 @@ fn a_preset_changed_in_an_effect_editor_is_stored_once() {
fn preferences_saved_before_the_window_was_remembered_keep_full_scale() {
let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap();
assert_eq!((ui.window_size, ui.window_maximized, ui.ui_scale), ([1600.0, 1000.0], false, 1.0));
assert_eq!(ui.reduce_visual_effects, crate::preferences::ReduceVisualEffects::Never);
assert_eq!(ui.undo_levels, 64);
}
#[test]
+370 -35
View File
@@ -3,11 +3,11 @@
use crate::theme::{Tokens, bold, mono, regular};
use crate::widgets::{icon_button, key, key_fill};
use crate::{SoundApp, icons};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use egui::{Align2, Color32, CornerRadius, Painter, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_engine::edit::{SHUFFLE_LOCK, SNAP_TO_GRID};
use soundcraft_model::{EditMode, Tool};
use soundcraft_time::{TimeFormat, format_length};
use soundcraft_model::{EditMode, EditState, Session, Tool};
use soundcraft_time::{FeetFramesValue, GridScale, GridValue, NoteValue, TimeFormat, TimecodeValue, format_length};
pub const HEIGHT: f32 = 78.0;
@@ -205,14 +205,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let e = app.engine.session().edit.clone();
let toggles: [(&str, &str, bool, &str); 5] = [
("options.tab_to_transient", "tab_transient", e.tab_to_transient, "Tab to Transient"),
("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"),
("options.link_track_edit", "link", e.link_track_edit, "Link Track and Edit Selection"),
("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"),
("options.automation_follows_edit", "wave", e.automation_follows_edit, "Automation Follows Edit"),
];
for (id, icon, on, tip) in toggles {
for (id, icon, on, tip) in option_toggles(&e) {
if icon_button(ui, vec2(36.0, 18.0), icon, on, tip).clicked() {
let _ = app.run(id, json!({}));
}
@@ -227,6 +220,18 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) {
});
}
/// The Edit-window toolbar's option toggles: (command id, icon, on, tooltip).
fn option_toggles(e: &EditState) -> [(&'static str, &'static str, bool, &'static str); 6] {
[
("options.tab_to_transient", "tab_transient", e.tab_to_transient, "Tab to Transient"),
("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"),
("options.link_track_edit", "link_track", e.link_track_edit, "Link Track and Edit Selection"),
("options.insertion_follows_playback", "insertion_follows", e.insertion_follows_playback, "Insertion Follows Playback"),
("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"),
("options.automation_follows_edit", "wave", e.automation_follows_edit, "Automation Follows Edit"),
]
}
fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String {
let s = app.engine.session();
s.format_position(at, f)
@@ -276,12 +281,12 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim);
}
/// Grid-line toggle, grid value menu and nudge value menu.
/// Draws the compact Grid and Nudge readouts and their scale menus.
fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let (grid, nudge, lines) = {
let (grid, nudge, lines, grid_follow, nudge_follow, clips_markers) = {
let e = &app.engine.session().edit;
(e.grid, e.nudge, e.grid_lines)
(e.grid, e.nudge, e.grid_lines, e.grid_follow_main, e.nudge_follow_main, e.grid_clips_markers)
};
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, t.counter_border), StrokeKind::Inside);
@@ -293,39 +298,243 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
let _ = app.run("view.grid_lines", json!({}));
}
let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0));
ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid.label(), mono(11.0), t.counter_text);
let grid_label = if clips_markers { GridScale::ClipsMarkers.label().to_string() } else { compact_grid_label(app.engine.session(), grid) };
if !clips_markers {
draw_compact_note_icon(ui.painter(), gr, grid, t.counter_text);
}
ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid_label, mono(11.0), t.counter_text);
let gresp = ui.interact(gr, ui.scope_id().with("grid_value"), Sense::click());
value_menu(app, &gresp, "view.grid");
value_menu(app, &gresp, "view.grid", grid, grid_follow, clips_markers, true);
ui.painter().text(pos2(r.min.x + 10.0, r.min.y + 44.0), Align2::LEFT_CENTER, "Nudge", regular(11.0), t.counter_label);
let nr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 34.0), pos2(r.max.x - 4.0, r.min.y + 54.0));
ui.painter().text(pos2(nr.max.x - 14.0, nr.center().y), Align2::RIGHT_CENTER, nudge.label(), mono(11.0), t.counter_text);
draw_compact_note_icon(ui.painter(), nr, nudge, t.counter_text);
ui.painter().text(
pos2(nr.max.x - 14.0, nr.center().y),
Align2::RIGHT_CENTER,
compact_grid_label(app.engine.session(), nudge),
mono(11.0),
t.counter_text,
);
let nresp = ui.interact(nr, ui.scope_id().with("nudge_value"), Sense::click());
value_menu(app, &nresp, "view.nudge");
value_menu(app, &nresp, "view.nudge", nudge, nudge_follow, false, false);
for rr in [gr, nr] {
icons::draw(ui.painter(), Rect::from_center_size(pos2(rr.max.x - 6.0, rr.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim);
}
}
fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str) {
egui::Popup::menu(resp).show(|ui| {
for v in ["1 bar", "1/2", "1/4", "1/8", "1/16", "1/32", "1/64", "1/8t", "1/16t", "1/4."] {
if ui.button(v).clicked() {
let _ = app.run(cmd, json!({"value": v}));
/// Formats a Grid/Nudge increment in the compact counter style for its time scale.
fn compact_grid_label(session: &Session, value: GridValue) -> String {
match value {
GridValue::Note { value, dotted, triplet } => {
let at_tick = session.tempo.samples_to_ticks(session.edit.selection.start, session.sample_rate);
let meter = session.tempo.meter_at_tick(at_tick);
let mut ticks = if value == NoteValue::Bar { meter.ticks_per_bar() } else { value.ticks() };
if dotted {
ticks = ticks * 3 / 2;
}
if triplet {
ticks = ticks * 2 / 3;
}
let per_bar = meter.ticks_per_bar().max(1);
let per_beat = meter.ticks_per_beat().max(1);
let bars = ticks / per_bar;
let remaining = ticks % per_bar;
format!("{bars}|{}|{:03}", remaining / per_beat, remaining % per_beat)
}
GridValue::Seconds(seconds) => {
let samples = session.sample_rate.samples(seconds);
format_length(session.edit.selection.start, samples, TimeFormat::MinSecs, session.sample_rate, &session.tempo, session.frame_rate)
}
GridValue::Frames(frames) => {
let samples = session.frame_rate.samples_at_frame(frames, session.sample_rate);
format!(
"{}.00",
format_length(session.edit.selection.start, samples, TimeFormat::Timecode, session.sample_rate, &session.tempo, session.frame_rate)
)
}
GridValue::Timecode(value) => match value {
TimecodeValue::Second => "00:00:01:00.00",
TimecodeValue::FiveFrames => "00:00:00:05.00",
TimecodeValue::Frame => "00:00:00:01.00",
TimecodeValue::HalfFrame => "00:00:00:00.50",
TimecodeValue::QuarterFrame => "00:00:00:00.25",
TimecodeValue::SubFrame => "00:00:00:00.01",
}
.to_string(),
GridValue::FeetFrames(value) => match value {
FeetFramesValue::Foot => "1+00",
FeetFramesValue::Frame => "0+01",
FeetFramesValue::QuarterFrame => "0+00.25",
FeetFramesValue::SubFrame => "0+00.01",
}
.to_string(),
GridValue::Samples(samples) => samples.to_string(),
}
}
/// Draws the musical symbol beside a compact Bars|Beats value.
fn draw_compact_note_icon(painter: &Painter, rect: Rect, value: GridValue, color: Color32) {
let GridValue::Note { value, dotted, triplet } = value else { return };
draw_note_value_icon(painter, Rect::from_min_size(pos2(rect.min.x + 1.0, rect.min.y + 2.0), vec2(14.0, 16.0)), value, dotted, triplet, color);
}
/// Draws a musical duration and its optional dotted or triplet modifier.
fn draw_note_value_icon(painter: &Painter, rect: Rect, value: NoteValue, dotted: bool, triplet: bool, color: Color32) {
let stroke = Stroke::new(1.2, color);
let head = pos2(rect.min.x + 4.0, rect.max.y - 3.5);
if value == NoteValue::Bar {
painter.line_segment([pos2(rect.min.x + 2.0, rect.min.y + 2.0), pos2(rect.min.x + 2.0, rect.max.y - 2.0)], stroke);
painter.line_segment([pos2(rect.min.x + 5.0, rect.min.y + 2.0), pos2(rect.min.x + 5.0, rect.max.y - 2.0)], stroke);
painter.line_segment([pos2(rect.min.x + 2.0, rect.min.y + 3.0), pos2(rect.min.x + 10.0, rect.min.y + 3.0)], stroke);
} else {
let hollow = matches!(value, NoteValue::Whole | NoteValue::Half);
if hollow {
painter.circle_stroke(head, 2.2, stroke);
} else {
painter.circle_filled(head, 2.2, color);
}
if value != NoteValue::Whole {
let stem_x = head.x + 2.0;
painter.line_segment([pos2(stem_x, head.y), pos2(stem_x, rect.min.y + 2.0)], stroke);
let flags = match value {
NoteValue::Eighth => 1,
NoteValue::Sixteenth => 2,
NoteValue::ThirtySecond => 3,
NoteValue::SixtyFourth => 4,
_ => 0,
};
for flag in 0..flags {
let y = rect.min.y + 2.0 + flag as f32 * 2.0;
painter.line_segment([pos2(stem_x, y), pos2(stem_x + 4.0, y + 2.5)], stroke);
}
}
}
if dotted {
painter.circle_filled(pos2(rect.max.x - 1.5, head.y), 1.0, color);
}
if triplet {
painter.text(pos2(rect.min.x, rect.min.y), Align2::LEFT_TOP, "3", regular(7.0), color);
}
}
/// Shows the complete Grid or Nudge increment, scale, and follow-mode menu.
fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str, current: GridValue, follow_main: bool, clips_markers: bool, allow_clips: bool) {
egui::Popup::menu(resp).show(|ui| {
ui.set_min_width(210.0);
match current.scale() {
GridScale::BarsBeats => {
let selected = match current {
GridValue::Note { value, .. } => value,
_ => NoteValue::Sixteenth,
};
let suffix = match current {
GridValue::Note { dotted: true, .. } => ".",
GridValue::Note { triplet: true, .. } => "t",
_ => "",
};
for value in NoteValue::ALL {
let id = match value {
NoteValue::Bar => "1 bar",
NoteValue::Whole => "whole",
NoteValue::Half => "1/2",
NoteValue::Quarter => "1/4",
NoteValue::Eighth => "1/8",
NoteValue::Sixteenth => "1/16",
NoteValue::ThirtySecond => "1/32",
NoteValue::SixtyFourth => "1/64",
};
menu_note_value(ui, app, cmd, selected == value, value, json!(format!("{id}{suffix}")));
}
ui.separator();
let (value, dotted, triplet) = match current {
GridValue::Note { value, dotted, triplet } => (value, dotted, triplet),
_ => (NoteValue::Sixteenth, false, false),
};
let base = match value {
NoteValue::Bar => "1 bar",
NoteValue::Whole => "whole",
NoteValue::Half => "1/2",
NoteValue::Quarter => "1/4",
NoteValue::Eighth => "1/8",
NoteValue::Sixteenth => "1/16",
NoteValue::ThirtySecond => "1/32",
NoteValue::SixtyFourth => "1/64",
};
menu_modifier(ui, app, cmd, dotted, "Dotted", "·", json!(if dotted { base.to_string() } else { format!("{base}.") }));
menu_modifier(ui, app, cmd, triplet, "Triplet", "3", json!(if triplet { base.to_string() } else { format!("{base}t") }));
}
GridScale::MinSecs => {
for seconds in [1.0, 0.5, 0.1, 0.01, 0.001] {
let value = GridValue::Seconds(seconds);
menu_value(ui, app, cmd, current == value, &value.label(), json!({"seconds": seconds}));
}
}
GridScale::Timecode => {
for value in TimecodeValue::ALL {
let selected = current == GridValue::Timecode(value)
|| matches!(
(current, value),
(GridValue::Frames(5), TimecodeValue::FiveFrames) | (GridValue::Frames(1), TimecodeValue::Frame)
);
menu_value(ui, app, cmd, selected, value.label(), json!({"timecode": value.id()}));
}
}
GridScale::FeetFrames => {
for value in FeetFramesValue::ALL {
menu_value(ui, app, cmd, current == GridValue::FeetFrames(value), value.label(), json!({"feet_frames": value.id()}));
}
}
GridScale::Samples => {
for samples in [10_000, 1_000, 100, 10, 2, 1] {
let value = GridValue::Samples(samples);
menu_value(ui, app, cmd, current == value, &value.label(), json!({"samples": samples}));
}
}
GridScale::ClipsMarkers => {}
}
ui.separator();
for (label, v) in [
("1 msec", json!({"seconds": 0.001})),
("10 msec", json!({"seconds": 0.01})),
("100 msec", json!({"seconds": 0.1})),
("1 second", json!({"seconds": 1.0})),
("1 frame", json!({"frames": 1})),
("100 samples", json!({"samples": 100})),
] {
if ui.button(label).clicked() {
let _ = app.run(cmd, json!({"value": v}));
for scale in GridScale::TIME {
let selected = !clips_markers && current.scale() == scale;
if ui.selectable_label(selected, scale.label()).clicked() {
let _ = app.run(cmd, json!({"format": scale.id(), "follow_main": false}));
ui.close();
}
}
if allow_clips && ui.selectable_label(clips_markers, GridScale::ClipsMarkers.label()).clicked() {
let _ = app.run(cmd, json!({"format": GridScale::ClipsMarkers.id(), "follow_main": false}));
ui.close();
}
ui.separator();
if ui.selectable_label(follow_main, "Follow Main Time Scale").clicked() {
let _ = app.run(cmd, json!({"follow_main": !follow_main}));
ui.close();
}
});
}
/// Adds one text menu value and dispatches its command when selected.
fn menu_value(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, label: &str, value: serde_json::Value) {
if ui.selectable_label(selected, label).clicked() {
let _ = app.run(cmd, json!({"value": value}));
ui.close();
}
}
/// Adds one musical value with its duration symbol.
fn menu_note_value(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, note: NoteValue, value: serde_json::Value) {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
draw_note_value_icon(ui.painter(), rect, note, false, false, ui.visuals().text_color());
menu_value(ui, app, cmd, selected, note.label(), value);
});
}
/// Adds a dotted or triplet modifier with its compact symbol.
fn menu_modifier(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, label: &str, symbol: &str, value: serde_json::Value) {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
ui.painter().text(rect.center(), Align2::CENTER_CENTER, symbol, regular(11.0), ui.visuals().text_color());
menu_value(ui, app, cmd, selected, label, value);
});
}
@@ -385,8 +594,7 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) {
});
}
/// Count-off, meter and tempo readouts; the tempo value is editable in place for the tempo event at or before the
/// playhead.
/// Count-off, meter and tempo readouts; the tempo value edits the event at or before the playhead.
fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let (bpm, meter, countoff, click, bars) = {
@@ -448,6 +656,7 @@ fn tempo_value(app: &mut SoundApp, ui: &mut Ui, right: f32, y: f32, bpm: f64) {
resp.on_hover_text("Drag to change the tempo (Shift: fine steps), or click to type it");
}
/// Draws the compact stereo main-output meter.
fn output_meter(app: &mut SoundApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(26.0, 64.0), Sense::hover());
let m = app.main_meter;
@@ -457,3 +666,129 @@ fn output_meter(app: &mut SoundApp, ui: &mut Ui) {
crate::widgets::meter(ui, rr, m.level[c], m.hold[c], m.clip);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use egui_kittest::{Harness, kittest::Queryable};
/// Builds a compact Grid/Nudge toolbar test harness.
fn harness() -> Harness<'static, SoundApp> {
let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let mut harness = Harness::builder().with_size(vec2(700.0, 700.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let edit = &app.engine.session().edit;
let (grid, grid_follow, clips, nudge, nudge_follow) =
(edit.grid, edit.grid_follow_main, edit.grid_clips_markers, edit.nudge, edit.nudge_follow_main);
let grid_anchor = ui.button("Grid menu");
value_menu(app, &grid_anchor, "view.grid", grid, grid_follow, clips, true);
let nudge_anchor = ui.button("Nudge menu");
value_menu(app, &nudge_anchor, "view.nudge", nudge, nudge_follow, false, false);
},
app,
);
harness.run_steps(3);
harness
}
/// Opens a toolbar value menu and lets the popup settle.
fn open(harness: &mut Harness<SoundApp>, label: &str) {
harness.get_by_label(label).click();
harness.run_steps(3);
}
/// Compact values use their corresponding counter formats and symbols.
#[test]
fn compact_grid_values_use_their_counter_formats() {
let session = Session::default();
let note = |value, dotted, triplet| GridValue::Note { value, dotted, triplet };
assert_eq!(compact_grid_label(&session, note(NoteValue::Quarter, false, false)), "0|1|000");
assert_eq!(compact_grid_label(&session, note(NoteValue::Eighth, false, true)), "0|0|320");
assert_eq!(compact_grid_label(&session, GridValue::Seconds(0.001)), "0:00.001");
assert_eq!(compact_grid_label(&session, GridValue::Timecode(TimecodeValue::Second)), "00:00:01:00.00");
assert_eq!(compact_grid_label(&session, GridValue::Timecode(TimecodeValue::QuarterFrame)), "00:00:00:00.25");
assert_eq!(compact_grid_label(&session, GridValue::FeetFrames(FeetFramesValue::Foot)), "1+00");
assert_eq!(compact_grid_label(&session, GridValue::Samples(10_000)), "10000");
}
/// The Grid menu exposes every scale and retains its numeric value in Clips/Markers mode.
#[test]
fn grid_menu_exposes_each_scale_and_keeps_the_increment_in_clips_markers_mode() {
let mut harness = harness();
open(&mut harness, "Grid menu");
for label in ["Bars|Beats", "Min:Secs", "Timecode", "Feet+Frames", "Samples", "Clips/Markers", "Follow Main Time Scale"] {
let _ = harness.get_by_label(label);
}
for label in ["1 bar", "Whole note", "1/2 note", "1/4 note", "1/8 note", "1/16 note", "1/32 note", "1/64 note", "Dotted", "Triplet"] {
let _ = harness.get_by_label(label);
}
harness.get_by_label("Timecode").click();
harness.run_steps(3);
open(&mut harness, "Grid menu");
for label in ["1 second", "5 frames", "1 frame", "1/2 frame", "1/4 frame", "1 subframe"] {
let _ = harness.get_by_label(label);
}
harness.get_by_label("1/4 frame").click();
harness.run_steps(3);
open(&mut harness, "Grid menu");
harness.get_by_label("Clips/Markers").click();
harness.run_steps(3);
let edit = &harness.state().engine.session().edit;
assert_eq!(edit.grid, GridValue::Timecode(TimecodeValue::QuarterFrame));
assert!(edit.grid_clips_markers);
open(&mut harness, "Grid menu");
harness.get_by_label("1/2 frame").click();
harness.run_steps(3);
let edit = &harness.state().engine.session().edit;
assert_eq!(edit.grid, GridValue::Timecode(TimecodeValue::HalfFrame));
assert!(!edit.grid_clips_markers);
}
/// Selecting another musical increment preserves the active dotted or triplet modifier.
#[test]
fn note_menu_keeps_the_current_modifier_when_changing_value() {
let mut harness = harness();
harness.state_mut().run("view.grid", json!({"value": "1/8t"})).unwrap();
harness.run_steps(3);
open(&mut harness, "Grid menu");
harness.get_by_label("1/16 note").click();
harness.run_steps(3);
assert_eq!(harness.state().engine.session().edit.grid, GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: true });
open(&mut harness, "Grid menu");
harness.get_by_label("Whole note").click();
harness.run_steps(3);
assert_eq!(harness.state().engine.session().edit.grid, GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: true });
}
/// The Nudge menu exposes every numeric scale without the Grid-only Clips/Markers mode.
#[test]
fn nudge_menu_has_every_numeric_scale_but_no_clips_markers_mode() {
let mut harness = harness();
open(&mut harness, "Nudge menu");
for label in ["Bars|Beats", "Min:Secs", "Timecode", "Feet+Frames", "Samples", "Follow Main Time Scale"] {
let _ = harness.get_by_label(label);
}
assert_eq!(harness.query_all_by_label("Clips/Markers").count(), 0);
harness.get_by_label("Samples").click();
harness.run_steps(3);
open(&mut harness, "Nudge menu");
for label in ["10000 samples", "1000 samples", "100 samples", "10 samples", "2 samples", "1 sample"] {
let _ = harness.get_by_label(label);
}
}
/// #1262: the two link toggles differ only by their icon, so each option toggle needs its own.
#[test]
fn option_toggles_have_distinct_icons() {
let toggles = option_toggles(&EditState::default());
let timeline = toggles.iter().find(|t| t.0 == "options.link_timeline_edit").unwrap();
let track = toggles.iter().find(|t| t.0 == "options.link_track_edit").unwrap();
assert_ne!(timeline.1, track.1, "the timeline and track links need different icons");
let mut icons: Vec<&str> = toggles.iter().map(|t| t.1).collect();
icons.sort_unstable();
icons.dedup();
assert_eq!(icons.len(), toggles.len(), "every option toggle needs its own icon");
}
}

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