Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 02:29:44 +00:00
Kushagra Kumar 966e253873 fix(engine): skip duplicate MIDI tempos only when session tempo matches
Collapse consecutive duplicate SMF tempos only if the session tempo at
the event tick already matches, so existing session tempo markers are
overridden as expected.
2026-10-31 05:50:50 +05:30
Dylan M. Taylor e8bc4758cc fix(flatpak): allow reading launcher version pins (#1210)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:19 -04:00
Kushagra Kumar af4dd7ce4c Contributors: add kk376 to people.toml 2026-10-31 05:35:13 +05:30
Kushagra Kumar f7b0805de6 fix(engine): bound MIDI import tempo and track floods (#890)
import_midi_bytes capped meter changes at MAX_MIDI_METERS (10,000), but
left imported tempo events and synth tracks unbounded. Dense MIDI files
inserted unlimited tempo markers into the session tempo map, degrading
linear tick-to-sample conversions across playback and audio processing,
and instantiated up to 4,096 synth tracks for controller-only tracks.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 06:40:00 +01:00
ydking0911 9f5f312051 Clear: close the gaps of clips removed by id in Shuffle 2026-10-30 14:30:58 +09:00
Dylan M. Taylor 92e74f6e00 Update prompt shows the app icon and accent color instead of a letter (#1198)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#1fa5c9, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
2026-10-30 01:28:31 -04:00
ydking0911 ffb95ebcf3 Clip menu: pass the clicked clip's track when Delete removes selected clips by id 2026-10-30 13:47:53 +09:00
NetterSilverandClaude Haiku 5.5 440999a51f Play transport.play {from} at the requested position, not the selection
Fixes #1069

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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 05:24:39 +01:00
ydking0911 14a081304b Clip menu: act on the whole edit selection when the right-clicked clip is part of it 2026-10-30 11:35:49 +09:00
Dylan M. Taylor 072e548ec8 deps: bump tar, zip, roxmltree, rustls-webpki and symphonia to their latest versions (#1192)
- `zip`: 9.1.1 → 9.1.3

Lockfile only.
2026-10-29 22:21:27 -04:00
Dylan M. Taylor 98fae00688 CI: workflows only call craft-common-actions at @main (#1190)
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
2026-10-29 22:11:16 -04:00
Brandon Thomas d2079bf09d Merge pull request #1194 from storytold/m/land-995
Bound the command journal (#995, rebased)
2026-10-29 22:04:22 -04:00
Brandon Thomas 5447c94cb6 Merge pull request #1193 from storytold/m/land-993
Bound Print Score and MusicXML output size (#993, rebased)
2026-10-29 22:04:18 -04:00
Brandon Thomas 3276358bef Merge pull request #1150 from ayozetr/fix-1146-send-fader-automation
Write send-level automation from the send faders (fixes #1146)
2026-10-29 22:03:28 -04:00
Brandon Thomas 081f43b90a Merge pull request #1180 from Silvertree2010/fix/h3951-h-264-picture-order-count-type-1
Use saturating arithmetic in H.264 POC type 1/2 computation
2026-10-29 22:03:24 -04:00
Brandon Thomas b4412929a7 Merge pull request #1109 from Silvertree2010/fix/h2757-audiosuite-processes-that-shorte
Clamp clip fades after AudioSuite processes that shorten a clip
2026-10-29 22:03:21 -04:00
Brandon Thomas ca27f0f8b4 Merge pull request #1057 from Silvertree2010/fix/h2373-trim-tool-lets-a-stretched-clip
Scale trim_clip file bounds by clip stretch
2026-10-29 22:03:18 -04:00
Brandon Thomas 5b69a11d89 Merge pull request #1020 from Silvertree2010/fix/h1998-clip-group-import-rejects-its-ow
Fix clip group import rejecting audio names containing ".."
2026-10-29 22:03:14 -04:00
Brandon ThomasandClaude Opus 5.5 81ab012724 Take the journal as a VecDeque in assert_history_unchanged
#995 makes the command journal a bounded VecDeque; the helper #1129 added
still took a slice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:00:11 -04:00
Brandon Thomas 5075da2f3e Merge remote-tracking branch 'origin/main' into land-993
# Conflicts:
#	crates/engine/src/score.rs
2026-10-29 21:59:42 -04:00
Brandon Thomas a62d955a39 Merge remote-tracking branch 'origin/main' into land-995 2026-10-29 21:59:42 -04:00
Brandon ThomasandClaude Opus 5.5 af4970e388 Saturate the reference POC LSB after a memory management op 5
A saturated top POC with a negative bottom field could still overflow
here, as CodeRabbit noted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:58:53 -04:00
Brandon Thomas 9e768d659b Merge remote-tracking branch 'origin/main' into fix1180 2026-10-29 21:58:16 -04:00
Brandon ThomasandClaude Opus 5.5 4e9d778734 Make the fade clamp test reach the fade-out bound too
A 10-sample fade-out was already shorter than the processed clip, so the
fade-out clamp went untested. Use 40, as CodeRabbit suggested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:58:10 -04:00
Brandon Thomas c2f14dff49 Merge remote-tracking branch 'origin/main' into fix1109 2026-10-29 21:57:47 -04:00
Brandon ThomasandClaude Opus 5.5 f39413033e Cover the start bound of trim_clip on a stretched clip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:57:36 -04:00
Brandon ThomasandClaude Opus 5.5 19f4b695e2 Merge origin/main into #1057; move its test to edit/tests.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:57:36 -04:00
Brandon ThomasandClaude Opus 5.5 17d553a7a7 Refuse absolute audio paths in clip group files
A bare group path leaves the folder empty, so an absolute audio path
passed the starts_with check. Reject root and prefix components too,
as CodeRabbit noted, and cover it in the regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:56:21 -04:00
Brandon Thomas 57ae0a8b5d Merge remote-tracking branch 'origin/main' into fix1020
# Conflicts:
#	crates/engine/src/clip_group_file.rs
2026-10-29 21:56:21 -04:00
Brandon Thomas 52083e07f1 Merge pull request #1159 from Silvertree2010/fix/h3519-insert-time-and-cut-time-leave-k
Move key signatures with Insert Time and Cut Time
2026-10-29 21:55:21 -04:00
Brandon Thomas b46584cc30 Merge remote-tracking branch 'origin/main' into fix1159 2026-10-29 21:54:18 -04:00
Brandon Thomas 52b5cea05f Merge pull request #1134 from Silvertree2010/fix/h2909-cut-time-and-insert-time-leave-a
Shift selection memory location ends on Cut Time and Insert Time
2026-10-29 21:54:11 -04:00
Brandon Thomas 228fd37ab7 Merge remote-tracking branch 'origin/main' into fix1134
# Conflicts:
#	crates/engine/tests/it/edit_modes.rs
2026-10-29 21:53:13 -04:00
Brandon Thomas 60d18f225d Merge pull request #1095 from dotCooCoo/fix/strip-silence-scan-stops
Strip Silence: closing the window while Mix shows stops the rectangle scan
2026-10-29 21:52:38 -04:00
Brandon Thomas ba0c6e381f Merge pull request #1106 from dotCooCoo/fix/wasm-clippy-drop-non-drop
Engine: wasm32 clippy reports no drop_non_drop in autosave and backups
2026-10-29 21:52:35 -04:00
Brandon Thomas bcb872f1a3 Merge pull request #1103 from dotCooCoo/fix/updates-ui-wasm-dead-code
Web: Check for Updates opens its window, with no dead_code warnings in xtask wasm
2026-10-29 21:52:31 -04:00
Brandon Thomas b119e3adce Merge pull request #1077 from sanderdewijs/fix/playback-engine-command
setup.playback_engine / setup.hardware: apply device and buffer in the app, don't claim it headless
2026-10-29 21:52:27 -04:00
Brandon Thomas 0d8dde8294 Merge pull request #1181 from reoniazx/fix/dirty-state-comparison
Avoid cloning sessions when checking unsaved changes
2026-10-29 21:52:24 -04:00
Brandon Thomas 94fb7db790 Merge pull request #1179 from luigiPinna/fix/video-sample-cap-912
Route sequential H.264 sample reads through the 256 MiB cap
2026-10-29 21:52:20 -04:00
Brandon Thomas 3f5377c877 Merge pull request #1178 from luigiPinna/fix/vst3-bus-limits-904
Reject VST3 bus layouts beyond host limits instead of truncating
2026-10-29 21:52:16 -04:00
Brandon Thomas 3b87c6dcc2 Merge pull request #1026 from CloudCorpRecords/fix/folder-delete-shared-bus
track.delete: keep a folder's input bus when surviving tracks route through it
2026-10-29 21:52:12 -04:00
Brandon Thomas d2244d0765 Merge pull request #1172 from TheGalitube/fix/h1672-bars-beats-conversion-panics-on
Clamp the minimum beat before conversion
2026-10-29 21:52:09 -04:00
Brandon Thomas e2809001cf Merge pull request #1171 from TheGalitube/fix/h1407-velocity-ramps-miscalculate-note
Calculate velocity ramps across the full tick range
2026-10-29 21:52:05 -04:00
Brandon Thomas 92c925a1dc Merge pull request #1149 from ayozetr/test-878-matroska-sizes
Audio import: regression test for oversized Matroska element sizes (fixes #878)
2026-10-29 21:52:01 -04:00
Brandon Thomas 140e63fe09 Merge pull request #1148 from ayozetr/fix-895-stretch-render-budget
Bound the source span of stretch renders (fixes #895)
2026-10-29 21:51:58 -04:00
Brandon Thomas c3cf5fe236 Merge pull request #1145 from DAnaeto/fix/cli-keep-going-exit-status
Fix exit status after kept-going script errors
2026-10-29 21:51:55 -04:00
Brandon Thomas 1ce93ab2ea Merge pull request #1169 from Silvertree2010/fix/h3883-setup-current-timecode-overflows
Saturate and clamp session start in setup.current_timecode
2026-10-29 21:51:51 -04:00
Brandon Thomas dd1ac56430 Merge pull request #1182 from Silvertree2010/fix/h3996-h-264-slice-header-panics-with-i
Return Error::Invalid for overflowing H.264 slice header values
2026-10-29 21:51:47 -04:00
Brandon Thomas fda41ecd12 Merge pull request #1161 from Silvertree2010/fix/h3574-new-memory-location-during-playb
Fix stale end on a Memory Location added during playback
2026-10-29 21:51:43 -04:00
Brandon Thomas 6a10dfa19d Merge pull request #1160 from Silvertree2010/fix/h3562-mcp-server-sends-no-reply-to-an
Reply with Invalid Request to empty batches and non-object messages
2026-10-29 21:51:39 -04:00
Brandon Thomas 672f5ab767 Merge pull request #1153 from Silvertree2010/test/reference-algorithms
Check the rewritten DSP paths against the algorithms they replaced
2026-10-29 21:51:35 -04:00
Brandon Thomas 11ab6a999c Merge pull request #1141 from Silvertree2010/fix/1113-audio-io-info-reports-more-frame
Report the decoded length for AAC in MP4 in audio-io probe
2026-10-29 21:51:31 -04:00
Brandon Thomas b84d6c95d5 Merge pull request #1138 from Silvertree2010/fix/h3004-moving-several-midi-notes-left-p
Keep MIDI note spacing when moving notes left past tick 0
2026-10-29 21:51:28 -04:00
Brandon Thomas 1d99907d85 Merge pull request #1137 from Silvertree2010/fix/h3028-pasting-a-crossfaded-clip-pair-d
Keep crossfade overlaps when pasting clips
2026-10-29 21:51:24 -04:00
Brandon Thomas 57b3ae5357 Merge pull request #1123 from Silvertree2010/fix/h2893-elastic-warp-to-a-shorter-length
Clamp clip fades after elastic warp and remove warp
2026-10-29 21:51:21 -04:00
Brandon Thomas 2e6c519bef Merge pull request #1110 from Silvertree2010/fix/h2771-bwf-bext-fields-are-cut-in-the-m
Cut BWF bext text fields at UTF-8 character boundaries
2026-10-29 21:51:18 -04:00
Brandon Thomas adc7c13a37 Merge pull request #1062 from Silvertree2010/fix/h2436-moving-several-clips-left-past-t
Keep clip spacing when moving a group left past the session start
2026-10-29 21:51:14 -04:00
Brandon Thomas f5d7ca5d87 Merge pull request #1061 from Silvertree2010/fix/h2409-musicxml-svg-score-export-writes
Strip XML-forbidden characters from MusicXML/SVG score export
2026-10-29 21:51:10 -04:00
Brandon Thomas a6df32ffab Merge pull request #1025 from Silvertree2010/fix/h2054-session-text-export-counts-midi
Count only audio-track clips in session text export
2026-10-29 21:51:07 -04:00
Brandon Thomas 7b654fd1a3 Merge pull request #1024 from Silvertree2010/fix/h2052-min-secs-position-strings-are-mi
Reject out-of-range timecode fields so Min:Secs strings fall through
2026-10-29 21:51:03 -04:00
NetterSilverandClaude Haiku 5.5 f16e312fbc Return Error::Invalid for overflowing H.264 slice header values
Found while testing crates/video/src/h264/slice.rs:202-204 (SliceHeader::parse)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 14:54:30 +01:00
reoniazx 986fb951df Avoid cloning sessions for dirty-state comparisons 2026-10-29 20:19:47 +08:00
NetterSilverandClaude Haiku 5.5 d9c293a92b Use saturating arithmetic in H.264 POC type 1/2 computation
Found while testing crates/video/src/h264/slice.rs:485-490 (PocState::compute, pic_order_cnt_type 1)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 12:05:31 +00:00
Luigi Pinna 5dbebaf617 Contributors: add luigiPinna to people.toml 2026-10-29 10:46:43 +01:00
Sander de WijsandClaude Opus 5.5 5c8654b2d7 Split the playback engine device test under the cognitive complexity limit
Upstream now denies clippy::cognitive_complexity (#1129). The test is
split into refusals, applying, and parity with ui.audio_device; the
assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:46:26 +01:00
Sander de WijsandClaude Opus 5.5 d97e5e7f16 Validate playback device command responses
Reject non-string device values before changing session or audio configuration, include bit depth in desktop responses, and report applied only for an open non-silent device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:39:42 +01:00
Sander de WijsandClaude Opus 5.5 a63b23adf7 Apply the playback engine's device and buffer in the desktop app
setup.playback_engine and setup.hardware stored device and buffer_size on the session and reported 1024 when nothing was set. Playback never read those values, so the open device stayed at its old buffer. In the app the commands now reopen the device through ui.audio_device and return the name, buffer and sample rate actually in use, while delay compensation still goes through the engine command. Headless keeps the stored request, returns applied: false, and leaves buffer_size null until one is set. A buffer is any integer from 16 to 8192 and is not rounded to a power of two; values outside that range error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:39:42 +01:00
Max MittelstädtandGPT-6-Luna 0ede26f5bc Clamp the minimum beat before conversion
Found while testing crates/time/src/tempo.rs:304

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-29 09:05:48 +00:00
Luigi Pinna 09444326d5 Reject VST3 bus layouts beyond host limits instead of truncating
Fixes #904
2026-10-29 09:42:56 +01:00
Luigi Pinna b609172db6 Route sequential H.264 sample reads through the 256 MiB cap
decode_h264 read each sample with Mp4File::read_sample directly, so a
sample table entry above 256 MiB was allocated and read in full when the
source was long enough. It now goes through Movie::read like the
keyframe and intra-frame paths, and an oversized sample is skipped like
any other unreadable access unit.

Fixes #912
2026-10-29 09:39:46 +01:00
Max MittelstädtandGPT-6-Luna 65e5443975 Calculate velocity ramps across the full tick range
Found while testing crates/midi/src/ops.rs:181

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-29 08:31:50 +00:00
NetterSilverandClaude Haiku 5.5 1c284e3703 Saturate and clamp session start in setup.current_timecode
Found while testing crates/engine/src/cmd/setup_more.rs:309-310 (current_timecode)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:08:00 +01:00
Dominic Anaeto 179f0c8c5d Merge main and preserve script failure status after CLI refactor 2026-10-28 22:55:08 -04:00
Dylan M. Taylor 8ca6939981 Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#1167)
* Document the aarch64 archive name in FreeBSD packaging

package.sh already honors CARGO_BUILD_TARGET: cargo builds for the target, the binaries are read
from target/<triple>/release, and the arch comes from the target rather than uname -m. Only the
header still described x86_64 alone, so update it.

* Include the full header in package.sh --help
2026-10-28 15:36:07 -04:00
Dylan M. Taylor 60b2c07a70 Grant network access so update checks can reach GitHub (#1163) 2026-10-28 13:16:03 -04:00
NetterSilverandClaude Haiku 5.5 e600e6bf64 Fix stale end on a Memory Location added during playback
Found while testing crates/engine/src/cmd/event.rs:305 (add_marker)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:06:40 +01:00
NetterSilverandClaude Haiku 5.5 90932afed0 Reply with Invalid Request to empty batches and non-object messages
Found while testing crates/automation/src/mcp.rs:201-205 (Server::handle) and :258-261 (Server::serve)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:16:19 +00:00
NetterSilverandClaude Haiku 5.5 50d0028e07 Move key signatures with Insert Time and Cut Time
Found while testing crates/engine/src/cmd/event.rs:147-196 (insert_time, cut_time); key_signatures in crates/model/src/session.rs:344

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:43:00 +00:00
dotCooCoo dcdcca4704 Zoom Toggle: a cancel with Zoom Toggle off keeps the scroll to restore
Cancel Zoom Toggle clears the saved vertical scroll only when it turns
Zoom Toggle off, so a cancel run before the next frame after turning it
off no longer drops the scroll position the toggle-off restores.
2026-10-28 03:31:06 -07:00
Daniel fd9c673694 Merge upstream main and reconcile Linux Wayland CSD dependencies
Preserve Unix X11 support, add Linux Adwaita no-title controls,
refresh Cargo.lock and keep MIT notice in existing packaging paths.
2026-10-28 10:48:57 +01:00
dotCooCoo 1a20190149 Merge commit 'refs/check/mw-soundcraft-main' into fix/updates-ui-wasm-dead-code
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-28 02:22:29 -07:00
dotCooCoo 1ffc164082 Merge commit 'refs/check/mw-soundcraft-main' into feat/zoom-toggle-cancel
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/edit_window.rs
#	crates/ui-egui/src/lib.rs
#	docs/ui-parity.md
2026-10-28 02:03:56 -07:00
dotCooCoo c14dc61473 Merge commit 'refs/check/mw-soundcraft-main' into fix/strip-silence-scan-stops
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-28 01:42:36 -07:00
NetterSilverandClaude Opus 5.5 ceb3404c31 Check the rewritten DSP paths against the algorithms they replaced
True peak, the Maximizer window minimum, flush and GainRamp are compared
bit for bit with their previous implementations computed in the test, so
the checks hold whatever the platform libm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:58:58 +01:00
Ayoze Torres 7c029c40ce Write send-level automation from the send faders (#1146)
Moving a send's level with automation enabled wrote nothing: the live
pass in write_automation only recorded each track's main fader
(`volume`). Send levels have their own lanes (`send_a_level` …), which
playback reads, but nothing wrote them while playing.

The send's level control in the strip slot and its floating send window
now start a SendFader gesture, released like the main fader's. The pass
writes the send's level with the track's mode, the same rules as the
volume: Write throughout, Touch while the send is dragged, Latch and
Touch/Latch from the first drag until stop (touched sends are kept per
track and slot).
2026-10-27 22:14:20 +00:00
Ayoze Torres ed0aed5ccb Matroska regression test: assert each case reaches the read it targets
The codec-data file now has a valid Info and track up to its oversized
CodecPrivate, and must fail with the end-of-file error from that read;
the block file must probe successfully, so decoding reaches the cluster.
An earlier rejection by anything else would now fail the test instead
of passing it without exercising the oversized read.
2026-10-27 21:34:25 +00:00
Ayoze Torres 1aab8e449d Audio import: regression test for oversized Matroska element sizes (#878)
With symphonia 0.5, the Matroska reader allocated whatever size an
element claimed before reading it, so a 30-byte file whose EBML header
claims 2^48 bytes aborted the process on import, drag and drop or
session open. Since the bump to symphonia 0.6 such reads grow with the
data actually read (safe_read_into_boxed_slice), so the abort is gone
without a SoundCraft-side check.

Lock that in: the reported file, a track whose CodecPrivate claims 2^46
bytes and a SimpleBlock that claims 2^47 bytes must give an error (or,
for the block, a best-effort read of what is there) under every hint,
never an abort. The test passes with memory capped at 1 GB.
2026-10-27 20:52:22 +00:00
Ayoze Torres c7a7cfa09b Bound the source span of stretch renders (#895)
render_stretched bounded only the output, at an hour. The source span it
reads in is the clip length over its stretch, which the 0.1..10 ratio
check lets reach ten hours, and it is held for every channel of the
source. A session's clip length and stretch are not trusted, so a
one-frame stereo file with a one-hour clip at stretch 0.1 made Remove
Pitch Shift allocate about 14 GB and abort, losing unsaved work.
Elastic Properties and TCE to Timeline go through the same code.

Both the source span and the output are now bounded by render_limit for
the source's channel count, like other offline renders, and the command
is refused before anything is allocated.
2026-10-27 20:48:57 +00:00
Dominic Anaeto 79c45a154d fix(cli): report kept-going script errors 2026-10-27 13:26:04 -04:00
Sly LaMenace d7c194d728 Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales 2026-10-27 15:54:41 +00:00
NetterSilverandClaude Opus 5.5 9adcc1f389 Report the decoded length for AAC in MP4 in audio-io probe
Fixes #1113

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:29:22 +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
NetterSilverandClaude Haiku 5.5 795f1cb6ef Keep MIDI note spacing when moving notes left past tick 0
Found while testing crates/engine/src/cmd/midi.rs:128-131 (notes_move)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 11:34:36 +00:00
NetterSilverandClaude Opus 5.5 e631966cf7 Keep crossfade overlaps when pasting clips
Found while testing crates/engine/src/edit.rs:235-248 (paste, via place_clip at :252-258)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:21:59 +00:00
NetterSilverandClaude Haiku 5.5 45498c8268 Shift selection memory location ends on Cut Time and Insert Time
Found while testing crates/engine/src/cmd/event.rs:162-167 (insert_time) and :188-194 (cut_time)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 02:34:20 +00:00
afa1532aba Mix: clamp clip-FX gain and send-level automation (fixes #884)
A shared session or an MCP clip.effects_set could store clip-FX gain or a
send-level automation point far above any fader (up to the +200 dB cap in
db_to_gain). Clamp clip-FX gain to the slider's +24 dB, send-level lanes to
+12 dB, and every loaded automation point to its AutoParam range on sanitize.

Co-Authored-By: opencode beam-free <noreply@opencode.ai>
Co-Authored-By: MiniMax M3 <noreply@minimax.io>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:53:47 +01:00
NetterSilverandClaude Haiku 5.5 0d0d0abc27 Clamp clip fades after elastic warp and remove warp
Found while testing crates/engine/src/cmd/clip.rs:217-231 (elastic), also :249 (remove_warp)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 21:44:02 +00:00
NetterSilverandClaude Haiku 5.5 9dc26fa1ca Cut BWF bext text fields at UTF-8 character boundaries
Found while testing crates/audio-io/src/wav.rs:343-348 (put_fixed_str, used by bext_chunk)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 06:50:47 +01:00
NetterSilverandClaude Haiku 5.5 75e5b90a86 Clamp clip fades after AudioSuite processes that shorten a clip
Found while testing crates/engine/src/cmd/audiosuite.rs:132-137

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 05:37:43 +00:00
Rene Turcios 7a9b3e38e1 Merge main into fix/folder-delete-shared-bus
Bring the branch up to date with upstream main (64 commits). The only
overlap was crates/engine/src/cmd/track.rs, resolved by keeping the
upstream refactor that extracts the surviving-parent walk into
surviving_parent, and keeping this branch's change on top of it: the
removed-bus computation now computes the replacement from that helper
and still drops the bus only when bus_used_beyond_folder says no
surviving track routes through it, so a shared folder bus and every
route to it survive the folder delete.

Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com>
2026-10-25 21:16:40 -07:00
dotCooCoo 9a99c82d2c Engine: wasm32 clippy reports no drop_non_drop in autosave and backups
On wasm32-unknown-unknown, std::fs::File has no Drop, so clippy with
-D warnings reports the three explicit drops of a File or Option<File>
in soundcraft-engine as drop_non_drop errors: Autosave::discard,
autosave::leftovers and io::copy_new.

Autosave::discard now releases the lock by setting the field to None,
which closes the file on every target. The drops in leftovers and
copy_new carry #[cfg(not(target_arch = "wasm32"))], as the DirBuilder
mode call in Autosave::create carries #[cfg(unix)]. The desktop builds
close the same files at the same points as before.
2026-10-25 21:08:33 -07:00
dotCooCoo 0c7212854a Web: Check for Updates opens its window, with no dead_code warnings in xtask wasm
SoundApp::logic and SoundApp::ui called updates_ui::tick and
updates_ui::show only off wasm32, so `cargo xtask wasm` reported both
functions as never used. On the web, SoundCraft > Check for Updates…
sets the window's message ("Update check is not supported in the web
browser.") but nothing drew the window.

Both functions already have wasm32 branches, so the calls now run on
every target, as effectcraft's lib.rs calls them.
2026-10-25 17:36:40 -07:00
dotCooCoo 84e591e1ca Zoom Toggle: Alt+Shift+E and Alt-click cancel it and keep the current view
Cancel Zoom Toggle (view.zoom_toggle_cancel, Alt+Shift+E, Option+Shift+E
on a Mac) turns Zoom Toggle off without going back to the view it
stored: the zoom, the horizontal and vertical scroll and the track
heights stay as they are. An Alt-click (Option-click) on the Zoom Toggle
button runs the same command. The next Zoom Toggle zooms in from the
current view and goes back to it.

When the command runs, from a key, the button or the control channel,
the Edit window clears the vertical scroll position that Zoom Toggle
would restore.
2026-10-25 17:17:19 -07:00
Daniel 1f8b267722 Merge upstream main and resolve GNOME Wayland controls conflicts 2026-10-25 23:57:47 +01:00
dotCooCoo ee55acbc93 Strip Silence: closing the window while Mix shows stops the rectangle scan
Only the Edit window dropped the Strip Silence rectangles and their scan
when Strip Silence closed. With the Mix window showing, a scan for old
values ran to the end and strip_scan_running() stayed true.
dialogs::show, which runs every frame, now drops them whenever neither
Strip Silence nor its Rename Selected Clips dialog is open.

edit.strip_silence_preview, strip_silence_scan and strip_settings move
from cmd/edit.rs to a new cmd/strip_silence.rs with its own specs(), as
AGENTS.md asks for new commands. edit.strip_silence reads its settings
through the same strip_settings.
2026-10-25 14:13:49 -07:00
Samuel Bacaro e294aa48c0 score: keep the bar duration when the item cap drops notes
A 64/1 bar can hold 256 sixteenths, more than MAX_ITEMS_PER_MEASURE (128). The
cap used to truncate the item list and drop the remaining duration, so MusicXML
wrote an underfull measure and SVG divided positions by the shortened total,
moving the kept notes across the bar.

The discarded suffix is now replaced with representable rests. The cap shrinks
the retained prefix further when the rests would not fit, and a final truncate
still bounds a hostile meter whose whole-bar rests exceed the cap.

Regression test: item_cap_keeps_the_measure_duration (fails on the old
truncate-only code, passes with rests).
2026-10-25 09:55:14 -03:00
Samuel Bacaro 021caab499 score: make the MusicXML oversize test reach the measure check
The 1 KiB budget failed on the part-list check, so nothing exercised the MusicXML
per-measure budget. Measure the header (parts with no measures) and the full
document, pick a budget between them, and assert that the header fits while the
completed document is refused, so the test proves the refusal comes from the
measures. The 1 KiB run stays as the header-refusal case, and the SVG case is
unchanged.
2026-10-25 09:40:30 -03:00
Sly LaMenace 66f305b747 fix(ui): handle Grid and Nudge edge cases 2026-10-25 12:30:31 +00:00
Samuel Bacaro fa77e03e16 score: apply every byte-budget and per-part cap in the same place
Addresses the review threads on #993:

- MAX_NOTES_PER_PART: the layout loop stops collecting past 100k notes
  per part, so the raw note vector cannot grow unboundedly regardless
  of clip count.
- fill: a pitch the chord cap rejects no longer extends the onset end
  (e.0), so dropped pitches do not inflate chord duration; a retained
  pitch that reappears with a longer duration still does.
- musicxml now checks the budget before the per-measure loop (covers an
  oversized title/composer, escaped, even with zero measures), after the
  part-list, and at the end, so no growth path is unchecked.
- svg now checks every measure (rest-only or missing measures cannot
  grow the output untouched past the budget), every staff, every system
  and the final footer, so staff/clef/bracket growth is capped too.
- over_budget takes the budget as a parameter and the budgeted entry
  points stay separate so tests pass a tiny budget instead of building
  256 MiB. The oversize test shrank from ~256 MiB to ~64 KiB and the
  new header test checks the title/composer escape path directly.

Regression tests.
2026-10-25 09:23:40 -03:00
Samuel Bacaro ccd3fe7ca2 Bound Print Score and MusicXML output size
MAX_MEASURES limited the number of measures but not the items per
measure, pitches per chord, or total output bytes, so a hostile session
could produce an unbounded document (a 10M-note session became a ~20 GB
SVG and aborted).

- Limit items per measure and pitches per chord in layout/fill.
- Give svg() and musicxml() a 256 MiB byte budget; over it they return
  an error instead of growing without bound.
- write_score surfaces that error to the caller.

Fixes #921
2026-10-25 09:23:40 -03: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
NetterSilverandClaude Haiku 5.5 9f049ac7d0 Keep clip spacing when moving a group left past the session start
Found while testing crates/engine/src/edit.rs:486-489 (move_clips), reached via edit.move_clips in crates/engine/src/cmd/edit.rs:935

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:51:21 +00:00
NetterSilverandClaude Haiku 5.5 7509b64c5c Strip XML-forbidden characters from MusicXML/SVG score export
Found while testing crates/engine/src/score.rs:216-218 (xml_escape), used by musicxml() and svg()

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:39:26 +01:00
NetterSilverandClaude Haiku 5.5 dab64379cb Scale trim_clip file bounds by clip stretch
Found while testing crates/engine/src/edit.rs:756-758,770 (trim_clip, file closure)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 02:58:32 +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
Samuel Bacaro 4f771268b0 journal: measure params size via a bounded writer before cloning
Addresses the review on #995: the previous fix serialized the full
params clone (up to a multi-MiB line from MCP or the control channel)
before applying the 64 KiB cap, so one oversized request still made
several large allocations.

- journal_params serializes through a bounded std::io::Write sink that
  fails with WriteZero the moment the cap is passed, so an oversized
  value costs at most ~1x the cap and is never fully serialized.
- Only when serialization fits is the value cloned for the journal.
- Tests exercise the journal helper directly (no 10,500 track.new
  calls), check ring-buffer eviction (oldest dropped, newest kept) and
  the cap boundary.

Refines the fix for #926.
2026-10-24 17:25:53 -03:00
Rene Turcios 8385ed94c6 track.delete: keep a folder's input bus when surviving tracks route through it
owned_folder_bus treats any bus a folder takes as input as the folder's
own, but track.input or a session file can point a folder at any bus.
Deleting such a folder removed the bus and repaired every route to it:
inputs set to none, outputs rerouted, sends cleared. Point a folder at
the demo session's shared Verb bus and deleting it wiped every reverb
send in the session.

Remove the bus only when nothing that survives the delete routes
through it: an input, a send target, or an output from outside the
folder's own subtree, whose outputs the delete already reroutes to the
parent folder's bus.

Fixes #914

Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com>
2026-10-24 15:05:38 +00:00
NetterSilverandClaude Haiku 5.5 8f23afe462 Count only audio-track clips in session text export
Found while testing crates/engine/src/inspect.rs:116-120 (session_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:00:54 +02:00
NetterSilverandClaude Haiku 5.5 6d86a52310 Reject out-of-range timecode fields so Min:Secs strings fall through
Found while testing crates/time/src/timecode.rs:189-211 (Timecode::parse), reached via crates/engine/src/cmd/mod.rs:275-287 (position_param_raw)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:57:29 +02:00
NetterSilverandClaude Haiku 5.5 9d5f5784d5 Fix clip group import rejecting audio names containing ".."
Found while testing crates/engine/src/clip_group_file.rs:101

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:02:39 +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
Samuel Bacaro 2e057309b9 Bound the command journal
The command journal was a Vec that was never trimmed, so large params
(clips/indices arrays, junk fields from MCP or the control channel) were
kept for the life of the process.

- Store the journal as a VecDeque ring buffer capped at 10,000 entries
  (oldest are dropped).
- Cap a single entry's params at 64 KiB (serialized): a larger value is
  replaced by a {"__truncated__": true} marker before journaling.

Fixes #926
2026-10-23 18:59:00 -03: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
190 changed files with 11100 additions and 1009 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
+2
View File
@@ -19,6 +19,7 @@ jobs:
# The shared workflow creates the draft release, which needs write access to contents.
permissions:
contents: write
packages: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
@@ -40,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 }}
+2 -2
View File
@@ -26,8 +26,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: soundcraft
app-dir: SoundCraft
+64 -12
View File
@@ -1,13 +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).
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft-apps folder, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
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.
@@ -22,7 +71,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l <id>`), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names.
## Never crash
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.**
- No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API.
- The same exception, same rules, for `soundcraft-vst3-host` (`crates/vst3-host`), the isolated unsafe helper crate for VST3 plugin FFI (COM vtables via the `vst3` bindings, module loading, host-side COM objects).
@@ -32,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`)
@@ -67,7 +115,7 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc
`cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit.
## Release
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/releasing.md`.
## Roadmap
`ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry.
@@ -81,11 +129,15 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- 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.
+8 -8
View File
@@ -39,14 +39,14 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts
| Path | Author | Source | License |
|---|---|---|---|
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
+1 -5
View File
@@ -1,5 +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.
@AGENTS.md
Generated
+111 -31
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"
@@ -1058,17 +1064,17 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1091,7 +1097,7 @@ dependencies = [
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"thiserror 2.0.21",
]
@@ -1099,7 +1105,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1120,13 +1126,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -1415,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"
@@ -1468,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"
@@ -4005,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"
@@ -4279,7 +4330,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"chrono",
"clap",
@@ -4303,7 +4354,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"log",
"serde",
@@ -4313,7 +4364,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -4325,7 +4376,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"clap",
"log",
@@ -4338,7 +4389,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4351,11 +4402,12 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"clap",
"clap_complete",
"clap_mangen",
"log",
"same-file",
"serde",
"serde_json",
@@ -4368,7 +4420,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -4379,7 +4431,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"craft-docpath",
"log",
@@ -4402,7 +4454,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4412,7 +4464,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"log",
"rayon",
@@ -4428,7 +4480,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4441,7 +4493,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"cpal",
"log",
@@ -4456,7 +4508,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4466,11 +4518,12 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"craft-ui",
"craft-update",
"egui",
"egui_extras",
"egui_kittest",
"image",
"log",
@@ -4495,7 +4548,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"craft-media",
"image",
@@ -4507,7 +4560,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4523,7 +4576,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"eframe",
"egui",
@@ -4568,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"
@@ -4938,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"
@@ -5982,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",
@@ -6139,7 +6224,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"serde_json",
]
@@ -6359,9 +6444,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.1"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
@@ -6459,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"
+8 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.14.0"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -50,15 +50,18 @@ zip = { version = "9", default-features = false }
cpal = "0.18"
# Which file paths stored in a session may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
egui = { version = "0.37" }
# 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.
+21 -2
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
@@ -340,7 +351,15 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+6 -4
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change.
- **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
@@ -156,8 +157,8 @@ mutated and truncated movies. Field reports from the first public week are track
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the progress docs
set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours,
tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session.
- **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media
@@ -176,6 +177,7 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
@@ -204,6 +206,6 @@ mutated and truncated movies. Field reports from the first public week are track
| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h |
| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers |
| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added |
| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
| 2026-10-10 | major | Progress docs restructured: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list |
| 2026-10-06 | minor | Alpha and parity estimates |
+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
+8 -1
View File
@@ -154,7 +154,8 @@ pub(crate) struct RunArgs {
pub(crate) struct ScriptArgs {
/// The script to run
pub(crate) file: PathBuf,
/// Report a failing line and go on with the next
/// Continue after command errors and finish requested output, then exit nonzero if any command failed.
/// Malformed JSON stops immediately
#[arg(long)]
pub(crate) keep_going: bool,
#[command(flatten)]
@@ -167,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.
+2
View File
@@ -20,6 +20,8 @@ pub(crate) enum CliError {
ScriptJson { path: PathBuf, line: usize, source: serde_json::Error },
#[error("{}:{line}: {id}: {source}", path.display())]
ScriptLine { path: PathBuf, line: usize, id: String, source: EngineError },
#[error("script finished with command errors")]
ScriptFailed,
#[error("{}: {source}", path.display())]
File { path: PathBuf, source: std::io::Error },
#[error("{}: {source}", path.display())]
+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,
+4 -1
View File
@@ -75,6 +75,7 @@ pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write
let text = std::fs::read_to_string(path).map_err(|source| CliError::File { path: path.clone(), source })?;
let mut e = load_engine(&args.engine, err)?;
let keep = args.keep_going;
let mut failed = false;
for (n, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
@@ -90,10 +91,12 @@ pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write
return Err(failure);
}
outln!(err, "{failure}");
failed = true;
}
}
}
finish(&mut e, &args.engine, out)
finish(&mut e, &args.engine, out)?;
if failed { Err(CliError::ScriptFailed) } else { Ok(()) }
}
#[cfg(test)]
+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);
}
+46 -10
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]
@@ -238,13 +257,13 @@ fn script_line_errors_stop_unless_keep_going() {
let dir = sandbox("script-errors");
std::fs::write(dir.join("bad.txt"), "nosuch.cmd {}\ntrack.new {}\n").unwrap();
std::fs::write(dir.join("badjson.txt"), "track.new {x\n").unwrap();
// A stopping line error carries the `error:` prefix; a --keep-going one is a bare line.
// Kept-going line errors stay bare; a final error summary supplies the failing status.
let stop = invoke(&dir, &["script", "bad.txt"]);
assert_eq!((stop.code, stop.stdout.as_str()), (1, ""));
assert_eq!(stop.stderr, "error: bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
let keep = invoke(&dir, &["script", "bad.txt", "--keep-going"]);
assert_eq!((keep.code, keep.stdout.as_str()), (0, "track.new: {\"tracks\":[1]}\n"));
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
assert_eq!((keep.code, keep.stdout.as_str()), (1, "track.new: {\"tracks\":[1]}\n"));
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\nerror: script finished with command errors\n");
assert_fails(&invoke(&dir, &["script", "badjson.txt"]), "badjson.txt:1: bad JSON: key must be a string at line 1 column 2");
}
@@ -515,10 +534,9 @@ fn mcp_roots_with_connect_are_a_usage_error() {
);
}
/// A name that isn't valid Unicode works wherever the tool itself reads or writes the file
/// (`convert`); a session or bounce the engine opens or writes by text name is refused (exit 1),
/// naming the file. Run in-process: the names need no shell.
#[cfg(unix)]
/// Native file paths round-trip on filesystems that accept non-Unicode names. macOS rejects
/// these names with EILSEQ before the tool can write them. Run in-process: no shell is needed.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn non_unicode_file_names() {
use std::ffi::OsString;
@@ -543,8 +561,26 @@ fn non_unicode_file_names() {
std::fs::rename(dir.join("s.scraft"), latin1("scraft")).unwrap();
let (code, err) = run(vec!["info".into(), latin1("scraft").into()]);
assert_eq!(code, 0, "{err}");
let (code, err) = run(vec!["run".into(), "--demo".into(), "--bounce".into(), latin1("wav").into()]);
assert_eq!(code, 1);
assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Engine output paths enter JSON, so invalid Unicode must be refused even on a filesystem
/// that cannot create such a file (including macOS).
#[cfg(unix)]
#[test]
fn non_unicode_session_outputs_are_rejected() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let dir = sandbox("nonunicode-outputs");
for (flag, name) in [("--bounce", b"caf\xe9.wav".as_slice()), ("--save", b"caf\xe9.scraft".as_slice())] {
let path = dir.join(OsString::from_vec(name.to_vec()));
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = soundcraft_cli::run(vec!["run".into(), "--demo".into(), flag.into(), path.into_os_string()], &mut out, &mut err);
let err = String::from_utf8_lossy(&err);
assert_eq!(code, 1, "{err}");
assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}");
assert!(out.is_empty());
}
let _ = std::fs::remove_dir_all(&dir);
}
+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
View File
@@ -9,3 +9,4 @@ mod closed_stdout;
mod man_and_completions;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod run;
mod script_status;
+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",
@@ -0,0 +1,90 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let sequence = NEXT.fetch_add(1, Ordering::Relaxed);
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("soundcraft-script-status-{}-{stamp}-{sequence}", std::process::id()));
std::fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn invoke(dir: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).current_dir(dir).env("SOUNDCRAFT_NO_PREFS", "1").args(args).output().unwrap()
}
#[test]
fn keep_going_runs_later_commands_and_finishes_but_reports_failure() {
let dir = Scratch::new();
std::fs::write(
dir.0.join("audit.txt"),
"audit.deliberately_invalid {}\ntrack.new {\"name\":\"After failure\",\"count\":1,\"format\":\"stereo\"}\n",
)
.unwrap();
let stop = invoke(&dir.0, &["script", "audit.txt", "--save", "stopped.scraft"]);
assert_eq!(stop.status.code(), Some(1));
assert!(String::from_utf8_lossy(&stop.stderr).contains("audit.deliberately_invalid"));
assert!(!String::from_utf8_lossy(&stop.stdout).contains("track.new:"));
assert!(!dir.0.join("stopped.scraft").exists());
let keep = invoke(
&dir.0,
&["script", "audit.txt", "--keep-going", "--bounce", "after.wav", "--start", "0", "--end", "0.01", "--save", "after.scraft", "--inspect"],
);
assert_eq!(keep.status.code(), Some(1), "{}", String::from_utf8_lossy(&keep.stderr));
assert!(String::from_utf8_lossy(&keep.stderr).contains("audit.deliberately_invalid"));
let stdout = String::from_utf8_lossy(&keep.stdout);
assert!(stdout.contains("track.new:") && stdout.contains("After failure"), "{stdout}");
assert!(dir.0.join("after.wav").is_file());
let info = invoke(&dir.0, &["info", "after.scraft"]);
assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));
assert!(String::from_utf8_lossy(&info.stdout).contains("After failure"));
}
#[test]
fn successful_script_and_parsing_or_finish_failures_keep_their_status() {
let dir = Scratch::new();
std::fs::write(dir.0.join("good.txt"), "track.new {\"name\":\"Good\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
let success = invoke(&dir.0, &["script", "good.txt", "--keep-going"]);
assert!(success.status.success(), "{}", String::from_utf8_lossy(&success.stderr));
std::fs::write(dir.0.join("bad.txt"), "track.new {bad\ntrack.new {\"name\":\"Too late\"}\n").unwrap();
let malformed = invoke(&dir.0, &["script", "bad.txt", "--keep-going", "--save", "malformed.scraft"]);
assert_eq!(malformed.status.code(), Some(1));
assert!(String::from_utf8_lossy(&malformed.stderr).contains("bad JSON"));
assert!(!String::from_utf8_lossy(&malformed.stdout).contains("track.new:"));
assert!(!dir.0.join("malformed.scraft").exists());
std::fs::write(dir.0.join("parent"), "not a directory").unwrap();
for (option, path) in [("--save", "parent/out.scraft"), ("--bounce", "parent/out.wav")] {
let fail = invoke(&dir.0, &["script", "good.txt", "--keep-going", option, path]);
assert_eq!(fail.status.code(), Some(1), "{option}: {}", String::from_utf8_lossy(&fail.stderr));
assert!(String::from_utf8_lossy(&fail.stderr).contains("parent"));
}
std::fs::write(dir.0.join("engine.txt"), "session.save {}\ntrack.new {\"name\":\"After engine failure\",\"count\":1}\n").unwrap();
let fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "engine.scraft"]);
assert_eq!(fail.status.code(), Some(1));
assert!(String::from_utf8_lossy(&fail.stderr).contains("session.save"));
assert!(String::from_utf8_lossy(&fail.stdout).contains("track.new:"));
assert!(dir.0.join("engine.scraft").is_file());
let both_fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "parent/out.scraft"]);
assert_eq!(both_fail.status.code(), Some(1));
let errors = String::from_utf8_lossy(&both_fail.stderr);
assert!(errors.contains("session.save") && errors.contains("parent"), "{errors}");
assert!(!errors.contains("script finished with command errors"), "{errors}");
}
+5 -1
View File
@@ -27,9 +27,13 @@ serde_json.workspace = true
log.workspace = true
chrono.workspace = true
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Preserve X11 fallback while enabling font-free Wayland client-side controls.
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true
+1 -1
View File
@@ -185,7 +185,7 @@ fn app_icon() -> Option<egui::IconData> {
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(result) = craft_update::self_update::finish(&soundcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so every start-up record (and the engine's panic hook, installed with the first
+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);
+1 -1
View File
@@ -1,7 +1,7 @@
# SoundCraft app icon (placeholder)
**A nightingale singing**, as an engraving-style head-and-shoulders portrait in the Crafting Apps'
owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colour field, the bird
owl-template framing: a full-bleed colour field, the bird
in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off
the bottom and right edges, three song arcs leaving the bill.
+13 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -61,3 +58,14 @@ display_name = "Matt Smith-Burlage"
[people.ProdByBuddha]
sync_github_name = true
[people.luigiPinna]
real_name = "Luigi Pinna"
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.kk376]
real_name = "Kushagra Kumar"
sync_github_name = true
+1 -1
View File
@@ -13,7 +13,7 @@ serde.workspace = true
thiserror.workspace = true
log.workspace = true
# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-vst3-host: this crate
# An isolated unsafe helper crate, like soundcraft-vst3-host: this crate
# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
[lints.rust]
+1 -1
View File
@@ -9,7 +9,7 @@
//! values are the Audio Unit's own (plain) values, ranges and units, so no mapping is needed.
//! Boolean parameters become toggles, indexed ones starting at 0 become choices.
//!
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate (craftrules never-crash):
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate:
//! `unsafe` is denied crate-wide and allowed only in the private `ffi` module (hand-written
//! AudioToolbox / CoreFoundation declarations). The public API is safe, returns
//! `Result`/`Option` and never panics. Limits of in-process hosting: an Audio Unit that itself
+15 -4
View File
@@ -20,7 +20,7 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
// Whether the packet table lists each packet's size and frame count (variable per packet).
let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false);
while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) {
let (body, size) = (pos + 12, size as i64);
let body = pos + 12;
let used = match kind {
b"desc" if size == 32 => {
lists_sizes = be_u32(b, body + 16) == Some(0);
@@ -28,12 +28,12 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
described = true;
Some(32)
}
b"data" if size == -1 => Some(4),
b"data" if size == u64::MAX => Some(4),
b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20),
b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?,
b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
b"kuki" if size > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
b"desc" | b"pakt" | b"chan" => None,
_ => u64::try_from(size).ok(),
_ => Some(size),
};
match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) {
Some(next) if next < b.len() => pos = next,
@@ -85,3 +85,14 @@ fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
}
None
}
#[cfg(test)]
mod tests {
#[test]
fn rejects_cookie_size_above_signed_range() {
let mut bytes = b"caff\0\x01\0\0kuki".to_vec();
bytes.extend_from_slice(&(1u64 << 63).to_be_bytes());
assert!(super::check(&bytes, 0).is_err());
}
}
+6 -3
View File
@@ -511,10 +511,13 @@ pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
}
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
/// The vendor string is skipped unread.
/// Whether a vendor string or comment in a (little-endian) Vorbis comment block claims more than
/// the block holds.
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
let Some(vendor) = le_u32(b, body) else { return false };
let Some(vendor) = le_u32(b, body).filter(|_| body.saturating_add(4) <= end) else { return false };
if vendor as usize > end - (body + 4) {
return true;
}
let at = (body + 4).saturating_add(vendor as usize);
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
+10 -3
View File
@@ -24,14 +24,14 @@ pub struct Peaks {
struct Block {
min: f32,
max: f32,
squares: f32,
squares: f64,
}
impl Block {
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
fn point(s: f32) -> Block {
Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } }
Block { min: s, max: s, squares: if s.is_finite() { f64::from(s) * f64::from(s) } else { 0.0 } }
}
fn of(samples: &[f32]) -> Block {
@@ -146,7 +146,7 @@ impl Peaks {
.map(|w| {
w.map(|(a, b)| {
let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len()));
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt()
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f64).sqrt() as f32
})
})
.collect()
@@ -215,6 +215,13 @@ mod tests {
assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite));
}
#[test]
fn finite_large_samples_keep_finite_rms() {
let samples = vec![1e20_f32; BLOCK];
let peaks = Peaks::build(&samples);
assert_eq!(peaks.rms_spans(&samples, 0.0, BLOCK as f64, 0.0, 1), vec![Some(1e20)]);
}
#[test]
fn rms_spans_match_the_samples() {
let x = signal(20_000);
+35 -2
View File
@@ -580,13 +580,46 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
Ok((info, AudioBuffer { sample_rate, channels: planar }))
}
/// Frames symphonia's AAC decoder yields for every packet; it supports no other frame length.
const AAC_FRAMES_PER_PACKET: u64 = 1024;
/// Whether `track` of `reader` is AAC in MP4. The MP4 reader reports the stts total as the length,
/// but the decoder yields a whole frame per packet (sample durations are not applied), so a last
/// sample whose duration differs from a frame makes the two disagree.
fn is_mp4_aac(reader: &dyn FormatReader, track_id: u32) -> bool {
reader.format_info().short_name == "isomp4"
&& reader.tracks().iter().find(|t| t.id == track_id).and_then(audio_params).is_some_and(|p| p.codec == CODEC_ID_AAC)
}
/// The decoded length of AAC in MP4: count the track's packets without decoding them.
fn mp4_aac_frames(reader: &mut dyn FormatReader, track_id: u32) -> Result<u64> {
let mut frames: u64 = 0;
loop {
match reader.next_packet() {
Ok(Some(packet)) if packet.track_id == track_id => frames = frames.saturating_add(AAC_FRAMES_PER_PACKET),
Ok(Some(_)) => {}
Ok(None) => break,
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => break,
Err(e) => {
demux_failure(frames, e)?;
break;
}
}
}
Ok(frames)
}
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
guarded(|| {
let reader = match open(bytes, detected)? {
let mut reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader,
Opened::Empty(info) => return Ok(info),
};
let (_, info) = info_of(reader.as_ref(), detected)?;
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
if info.frames > 0 && info.channels > 0 && is_mp4_aac(reader.as_ref(), track_id) {
info.frames = mp4_aac_frames(reader.as_mut(), track_id)?;
}
if info.frames > 0 && info.channels > 0 {
return Ok(info);
}
+17 -2
View File
@@ -342,7 +342,10 @@ pub(crate) fn read_channel_mask(b: &[u8]) -> Option<u32> {
fn put_fixed_str(out: &mut Vec<u8>, s: &str, len: usize) {
let bytes = s.as_bytes();
let n = bytes.len().min(len);
let mut n = bytes.len().min(len);
while !s.is_char_boundary(n) {
n -= 1;
}
out.extend_from_slice(bytes.get(..n).unwrap_or_default());
out.resize(out.len() + (len - n), 0);
}
@@ -500,6 +503,7 @@ pub(crate) fn header(
) -> Result<Vec<u8>> {
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
let byte_rate = sample_rate.checked_mul(u32::from(block_align)).ok_or_else(|| AudioError::Encode("WAV byte rate overflow".into()))?;
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
@@ -508,7 +512,7 @@ pub(crate) fn header(
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
fmt.extend_from_slice(&sample_rate.to_le_bytes());
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
fmt.extend_from_slice(&byte_rate.to_le_bytes());
fmt.extend_from_slice(&block_align.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
if extensible {
@@ -557,3 +561,14 @@ pub(crate) fn header(
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
Ok(head)
}
#[cfg(test)]
mod tests {
use super::header;
#[test]
fn rejects_byte_rate_overflow() {
let result = header(2, 1_100_000_000, (16, false), None, None, 0, false);
assert!(result.is_err(), "overflowing byte rate was accepted");
}
}
+1
View File
@@ -4,6 +4,7 @@ mod decode_read_list;
mod file_format;
mod float_headroom;
mod malformed;
mod mp4_length;
mod roundtrip;
mod stream;
mod wav_empty_data;
+76 -1
View File
@@ -110,6 +110,15 @@ fn zero_channels_is_malformed() {
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
}
#[test]
fn aiff_negative_channel_count_is_malformed() {
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
aiff[comm + 8] = 0x80;
aiff[comm + 9] = 0;
assert!(probe(&aiff, None).is_err());
}
#[test]
fn zero_sample_rate_is_malformed() {
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
@@ -208,6 +217,70 @@ fn caf_sizes_beyond_the_file_are_errors_not_aborts() {
assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets")));
}
/// An EBML element: `id`, then `size` as an 8-byte vint (so any size up to 2^56 − 2 fits, and
/// `u64::MAX` writes the reserved "unknown size"), then `body`.
fn ebml(id: &[u8], size: u64, body: &[u8]) -> Vec<u8> {
let size = if size == u64::MAX { 0x01FF_FFFF_FFFF_FFFF } else { 0x0100_0000_0000_0000 | size };
[id, &size.to_be_bytes(), body].concat()
}
/// The EBML header of a Matroska file.
fn ebml_header() -> Vec<u8> {
let doc_type = ebml(&[0x42, 0x82], 8, b"matroska");
ebml(&[0x1A, 0x45, 0xDF, 0xA3], doc_type.len() as u64, &doc_type)
}
/// #878: symphonia's Matroska reader takes element sizes from the file. With symphonia 0.5 a size
/// far beyond the file was allocated up front and aborted the process (no `catch_unwind` can catch
/// an allocation failure); since 0.6 such reads grow with the data actually read. Keep it that way
/// for the header, for codec data and for block frames.
#[test]
fn matroska_sizes_beyond_the_file_are_errors_not_aborts() {
let huge = 1u64 << 47;
// The 30-byte file from the report: EBML header and DocType sizes of 2^48 − 1 and 2^47.
let reported = [
0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x42, 0x82, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6D,
0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61,
];
let segment = |body: Vec<u8>| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat();
let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat();
let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info);
// A valid 16-bit PCM track entry's children, followed by `extra`.
let pcm = |extra: &[u8]| {
let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat();
[
ebml(&[0xD7], 1, &[1]),
ebml(&[0x73, 0xC5], 1, &[1]),
ebml(&[0x83], 1, &[2]),
ebml(&[0x86], 13, b"A_PCM/INT/LIT"),
ebml(&[0xE1], audio.len() as u64, &audio),
extra.to_vec(),
]
.concat()
};
// That track with a CodecPrivate claiming 2^46 bytes, inside parents that claim even more: the
// segment is valid up to that read, which runs out of file.
let track = ebml(&[0xAE], huge, &pcm(&ebml(&[0x63, 0xA2], huge >> 1, &[0; 16])));
let codec_data = segment([info.clone(), ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)].concat());
// The plain track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data.
let track = pcm(&[]);
let track = ebml(&[0xAE], track.len() as u64, &track);
let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat());
let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], track.len() as u64, &track), block].concat());
let out_of_file = |r: Result<_, AudioError>| matches!(r, Err(AudioError::Malformed(m)) if m.contains("end of file"));
for hint in [None, Some("mkv"), Some("wav")] {
assert!(probe(&reported, hint).is_err(), "{hint:?}");
assert!(decode(&reported, hint).is_err(), "{hint:?}");
// Refused at the oversized read itself, not by anything before it.
assert!(out_of_file(probe(&codec_data, hint).map(|_| ())), "{hint:?}: {:?}", probe(&codec_data, hint).err());
assert!(out_of_file(decode(&codec_data, hint).map(|_| ())), "{hint:?}");
// The file and its track open, so decoding reaches the block; the block reader stops at the
// end of the file, a best-effort read of what is there.
assert!(probe(&cluster, hint).is_ok(), "{hint:?}: {:?}", probe(&cluster, hint).err());
assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}");
}
}
#[test]
fn well_formed_caf_still_reads() {
let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
@@ -302,6 +375,7 @@ fn packets_longer_than_any_codec_uses_are_refused() {
}
}
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
#[test]
fn flac_metadata_fields_beyond_their_block_are_refused() {
let with_block = |kind: u8, body: &[u8]| {
@@ -312,7 +386,8 @@ fn flac_metadata_fields_beyond_their_block_are_refused() {
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300);
let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat();
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] {
let vendor_overflow = [&0x7fff_fff0u32.to_le_bytes()[..], &0u32.to_le_bytes()].concat();
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment), with_block(4, &vendor_overflow)] {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block")));
}
}
+61
View File
@@ -0,0 +1,61 @@
//! The length `probe` reports for AAC in MP4 is the length `decode` produces (issue #1113).
use soundcraft_audio_io::{decode, probe};
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
}
/// A full atom (version 0, no flags) whose body is `fields` as big-endian u32s.
fn full(kind: &[u8; 4], fields: &[u32]) -> Vec<u8> {
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
atom(kind, &body)
}
/// An MPEG-4 descriptor with a one-byte length.
fn descriptor(tag: u8, body: &[u8]) -> Vec<u8> {
[&[tag, body.len() as u8][..], body].concat()
}
/// A silent AAC-LC raw frame for one channel: an SCE with `max_sfb` 0, then ID_END.
const SILENT_SCE: [u8; 4] = [0x00, 0xC8, 0x00, 0x07];
/// A mono 48 kHz AAC-LC MP4 without an edit list: `packets` frames, the last lasting `last_dur` in stts.
fn aac_mp4(packets: u32, last_dur: u32) -> Vec<u8> {
// AudioSpecificConfig: AAC-LC, 48 kHz, one channel.
let dsi = descriptor(5, &[0x11, 0x88]);
let dcd = descriptor(4, &[&[0x40, 0x15, 0, 0, 0][..], &[0; 8], &dsi].concat());
let es = descriptor(3, &[&[0, 1, 0][..], &dcd, &descriptor(6, &[2])].concat());
let esds = atom(b"esds", &[&[0; 4][..], &es].concat());
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
let entry = atom(b"mp4a", &[&base[..], &(48_000u32 << 16).to_be_bytes(), &esds].concat());
let total = (packets - 1) * 1024 + last_dur;
let moov = |offset: u32| {
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat());
let stbl = [
stsd,
full(b"stts", &[2, packets - 1, 1024, 1, last_dur]),
full(b"stsc", &[1, 1, packets, 1]),
full(b"stsz", &[SILENT_SCE.len() as u32, packets]),
full(b"stco", &[1, offset]),
];
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
let mdia = [full(b"mdhd", &[0, 0, 48_000, total, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, total, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
};
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
[ftyp, moov(offset), atom(b"mdat", &SILENT_SCE.repeat(packets as usize))].concat()
}
#[test]
fn aac_in_mp4_probe_length_matches_the_decoded_length() {
// A last sample lasting until the track end (longer than a packet), and a control with a short one.
for last_dur in [1145, 546] {
let file = aac_mp4(10, last_dur);
let (_, buf) = decode(&file, Some("m4a")).unwrap();
assert_eq!(buf.frames(), 10 * 1024, "decoded frames, last stts duration {last_dur}");
assert_eq!(probe(&file, Some("m4a")).unwrap().frames, buf.frames() as u64, "probed frames, last stts duration {last_dur}");
}
}
+10
View File
@@ -316,3 +316,13 @@ fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
let frames = u64::MAX / 4;
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
}
#[test]
fn bwf_truncation_keeps_utf8_boundaries() {
let src = sine(48_000, 1, 10);
let bwf = BwfInfo { originator: format!("{}é", "a".repeat(31)), description: format!("{}é", "d".repeat(255)), ..BwfInfo::default() };
let bytes = encode(&src, &EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }).unwrap();
let out = probe(&bytes, None).unwrap().bwf.unwrap();
assert_eq!(out.originator, "a".repeat(31));
assert_eq!(out.description, "d".repeat(255));
}
+15
View File
@@ -317,4 +317,19 @@ mod tests {
assert!(r.call("session.inspect", json!({})).is_ok());
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[test]
fn remote_returns_an_app_error_unchanged() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() };
std::thread::spawn(move || {
let Ok((mut sock, _)) = listener.accept() else { return };
let mut line = String::new();
let _ = BufReader::new(sock.try_clone().unwrap()).read_line(&mut line);
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": false, "error": "transport.locate: nothing to reset"}));
});
let mut r = Remote::new(&addr);
let err = r.call("engine.execute", json!({"command": "transport.locate"})).unwrap_err();
assert_eq!(err, "transport.locate: nothing to reset");
}
}
+42
View File
@@ -199,6 +199,9 @@ impl<B: Backend> Server<B> {
/// Handle one JSON-RPC message; returns the reply line (None for notifications).
pub fn handle(&mut self, msg: &Value) -> Option<Value> {
if !msg.is_object() {
return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request"}}));
}
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(json!({}));
@@ -255,6 +258,9 @@ impl<B: Backend> Server<B> {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(Value::Array(batch)) if batch.is_empty() => {
Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request: empty batch"}}))
}
Ok(Value::Array(batch)) => {
let replies: Vec<Value> = batch.iter().filter_map(|m| self.handle(m)).collect();
if replies.is_empty() { None } else { Some(Value::Array(replies)) }
@@ -270,3 +276,39 @@ impl<B: Backend> Server<B> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Headless;
use soundcraft_engine::Engine;
fn reply(line: &str) -> String {
let mut server = Server::new(Headless::new(Engine::default()));
let mut out = Vec::new();
server.serve(line.as_bytes(), &mut out).unwrap();
String::from_utf8(out).unwrap()
}
#[test]
fn invalid_requests_get_an_invalid_request_error() {
for input in ["[]", "42", "[1]", "\"x\""] {
let text = reply(input);
assert_eq!(text.lines().count(), 1, "input {input}: reply was {text:?}");
let v: Value = serde_json::from_str(text.trim()).unwrap();
let e = match &v {
Value::Array(a) => a[0].clone(),
other => other.clone(),
};
assert_eq!(e["error"]["code"], -32600, "input {input}");
assert_eq!(e["id"], Value::Null, "input {input}");
}
}
#[test]
fn notifications_still_get_no_reply() {
assert_eq!(reply(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), "");
let v: Value = serde_json::from_str(reply(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).trim()).unwrap();
assert_eq!(v["id"], 1);
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ harness = false
clap-sys = "0.5"
libloading = "0.9"
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
# The isolated unsafe helper crate: this crate opts out of the workspace
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
# FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
[lints.rust]
+1
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)
+39 -3
View File
@@ -5,7 +5,7 @@
//! plugins exactly like the built-in ones. Plugin ids are `clap:<CLAP plugin id>`; parameter ids
//! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`].
//!
//! This is SoundCraft's single isolated `unsafe` crate (craftrules never-crash): `unsafe` is
//! This is SoundCraft's single isolated `unsafe` crate: `unsafe` is
//! denied crate-wide and allowed only in the private `ffi` module. The public API is safe, returns
//! `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that itself crashes
//! (segfault) takes the process down with it; we cannot sandbox native code.
@@ -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;
+60
View File
@@ -395,3 +395,63 @@ impl LoudnessMeter {
gain_to_db(self.true_peak)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn noise(len: usize, seed: u64, amp: f32) -> Vec<f32> {
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
(0..len)
.map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
((x >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0) * amp
})
.collect()
}
/// True peak as first written: the history shifted every sample, each phase summed on its own.
fn true_peak_reference(coefs: &[[f32; TP_PHASES]; TP_TAPS], x: &[f32]) -> f32 {
let mut h = [0.0f32; TP_TAPS];
let mut tp = 0.0f32;
for v in x.iter().map(|v| if v.is_finite() { *v } else { 0.0 }) {
h.copy_within(0..TP_TAPS - 1, 1);
h[0] = v;
let mut peak = v.abs();
for p in 0..TP_PHASES {
let y: f32 = coefs.iter().zip(h.iter()).map(|(c, b)| c[p] * b).sum();
peak = peak.max(y.abs());
}
if peak > tp {
tp = peak;
}
}
tp
}
#[test]
fn true_peak_matches_the_shifted_history() {
let sr = 48_000.0;
let len = 20_000;
let mut l: Vec<f32> =
(0..len).map(|i| 0.6 * (std::f32::consts::TAU * 0.25 * i as f32 + 0.7).sin()).zip(noise(len, 1, 0.3)).map(|(a, b)| a + b).collect();
let r = noise(len, 2, 0.9);
l[123] = f32::NAN;
l[4_567] = f32::INFINITY;
for block in [1, 7, 480, len] {
let mut m = LoudnessMeter::new(sr, 2);
let want = true_peak_reference(&m.tp_coefs, &l).max(true_peak_reference(&m.tp_coefs, &r));
for at in (0..len).step_by(block) {
let n = block.min(len - at);
m.process(&[l[at..at + n].to_vec(), r[at..at + n].to_vec()], n);
}
assert_eq!(m.true_peak.to_bits(), want.to_bits(), "block {block}");
m.reset();
let quiet = noise(500, 3, 0.1);
m.process(&[quiet.clone(), quiet.clone()], 500);
assert_eq!(m.true_peak.to_bits(), true_peak_reference(&m.tp_coefs, &quiet).to_bits(), "after reset, block {block}");
}
}
}
+15 -2
View File
@@ -127,8 +127,11 @@ pub fn remove_dc(ch: &mut [Vec<f32>]) {
if c.is_empty() {
continue;
}
let mean = c.iter().filter(|v| v.is_finite()).map(|&v| f64::from(v)).sum::<f64>() / c.len() as f64;
let m = mean as f32;
let (sum, n) = c.iter().filter(|v| v.is_finite()).fold((0.0f64, 0usize), |(s, n), &v| (s + f64::from(v), n + 1));
if n == 0 {
continue;
}
let m = (sum / n as f64) as f32;
c.iter_mut().for_each(|s| *s -= m);
}
}
@@ -889,4 +892,14 @@ mod tests {
assert_eq!(ch[0][50], 1.0);
fade(&mut ch, 1000, 1000, FadeShape::SCurve);
}
#[test]
fn remove_dc_ignores_non_finite_samples_in_the_mean() {
let mut ch = vec![vec![f32::NAN, 1.0, 1.0, 1.0]];
remove_dc(&mut ch);
assert!(ch[0][1..].iter().all(|x| x.abs() < 1e-6), "left over dc {:?}", ch[0]);
let mut all_bad = vec![vec![f32::NAN, f32::INFINITY]];
remove_dc(&mut all_bad);
assert!(all_bad[0][0].is_nan() && all_bad[0][1].is_infinite());
}
}
+41
View File
@@ -671,3 +671,44 @@ impl Plugin for Maximizer {
self.gr_meter
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The window minimum as first written: the last `la` requests in a ring, scanned whole.
#[test]
fn window_min_matches_a_scan_of_the_last_requests() {
let mut x = 0x2545_f491_4f6c_dd1du64;
let mut next = move || {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x
};
for la in [1usize, 2, 3, 7, 64, 480] {
let mut m = Maximizer::new();
m.win = vec![(0, 1.0); la];
let mut ring = vec![1.0f32; la];
let mut at = 0;
for i in 0..6_000 {
if i == 3_000 {
m.reset();
ring.iter_mut().for_each(|v| *v = 1.0);
at = 0;
}
let r = next();
let req = match r % 16 {
0 => f32::NAN,
1 => 1.0,
2 => 0.25,
_ => (r >> 40) as f32 / (1u64 << 24) as f32 * 1.5,
};
ring[at] = req;
at = (at + 1) % la;
let want = ring.iter().copied().fold(1.0f32, f32::min);
assert_eq!(m.window_min(req, la).to_bits(), want.to_bits(), "la {la}, sample {i}");
}
}
}
}
+14
View File
@@ -310,3 +310,17 @@ pub(crate) fn sort_events<T: Copy>(ev: &mut [T], key: impl Fn(&T) -> usize) {
pub(crate) fn note_hz(note: f32) -> f32 {
440.0 * 2f32.powf((note - 69.0) / 12.0)
}
#[cfg(test)]
mod tests {
#[test]
fn flush_matches_the_finite_and_magnitude_test() {
let edge = 1.0e-25f32.to_bits();
let special = [0, 0x8000_0000, 1, edge - 1, edge, edge + 1, 0x7f7f_ffff, 0x7f80_0000, 0xff80_0000, 0x7fc0_0000, 0xffc0_0001];
for b in (0..=u32::MAX).step_by(997).chain(special).chain(special.map(|b| b | 0x8000_0000)) {
let x = f32::from_bits(b);
let want = if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 };
assert_eq!(super::flush(x).to_bits(), want.to_bits(), "{b:#x}");
}
}
}
+146 -12
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()))?;
@@ -255,7 +271,7 @@ impl Autosave {
/// Delete the folder: the session closed with nothing left to recover.
fn discard(mut self) {
drop(self.lock.take());
self.lock = None;
let _ = std::fs::remove_dir_all(&self.dir);
}
}
@@ -286,12 +302,11 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok());
let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified());
let recording = dir.join(RECORDING).is_file();
#[cfg(not(target_arch = "wasm32"))]
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 });
}
_ => {
@@ -303,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.
@@ -450,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);
@@ -506,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");
@@ -672,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
+39 -1
View File
@@ -89,7 +89,11 @@ fn decode_group_audio(
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
for (old, rel) in audio_entries {
let full = dir.join(rel);
if !full.starts_with(dir) || rel.contains("..") {
if !full.starts_with(dir)
|| Path::new(rel)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_)))
{
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
}
let key = crate::io::file_key(&full);
@@ -233,6 +237,40 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_accepts_audio_names_with_consecutive_dots_but_not_parent_dirs() {
let dir = std::env::temp_dir().join(format!("sc-grp-dots-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("drums.scgrp").to_string_lossy().to_string();
let mut e = crate::demo::demo_engine();
let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap();
let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect();
e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap();
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
for (i, rel) in file["audio"].as_object_mut().unwrap().values_mut().enumerate() {
let old = dir.join(rel.as_str().unwrap());
let new = format!("Wait... what_{i}.wav");
std::fs::rename(&old, dir.join(&new)).unwrap();
*rel = json!(new);
}
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(r.is_ok(), "{r:?}");
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!("../escape.wav");
}
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "{r:?}");
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!(dir.join("Wait... what_0.wav").to_string_lossy());
}
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn imported_clips_carry_no_auto_rename_scheme() {
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
+13 -4
View File
@@ -28,6 +28,7 @@ mod plugin_state;
mod query;
pub(crate) mod sampler;
mod setup_more;
mod strip_silence;
mod surround;
mod tab;
mod track;
@@ -63,7 +64,7 @@ mod panic_probe {
pub use file::bounce_job;
pub(crate) use edit::remember_selection;
pub use edit::strip_silence_scan;
pub use strip_silence::strip_silence_scan;
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
/// `Err(reason)` = disabled.
@@ -160,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(sampler::specs());
v.extend(video::specs());
v.extend(zoom_toggle::specs());
v.extend(strip_silence::specs());
#[cfg(test)]
v.extend(panic_probe::specs());
v
@@ -322,11 +324,15 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Opt
Err(bad(cmd, format!("`{key}` must be samples, {{seconds}}, or a time string")))
}
/// Range from `start`/`end` params, else the edit selection.
/// Range from `start`/`end`/`length` params. A missing `start` defaults to the edit selection start;
/// with neither `end` nor `length` either, the whole edit selection is used.
pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result<Range> {
let st = position_param(e, cmd, p, "start")?;
let mut st = if p.get("start").is_some_and(Value::is_null) { None } else { position_param(e, cmd, p, "start")? };
let en = position_param(e, cmd, p, "end")?;
let len = position_param(e, cmd, p, "length")?;
if st.is_none() && (en.is_some() || len.is_some()) {
st = Some(e.session().edit.selection.start);
}
match (st, en, len) {
(Some(a), Some(b), _) => Ok(Range::new(a, b)),
(Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))),
@@ -341,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)];
+31 -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}`")));
}
@@ -137,6 +140,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
nc.name = new_name;
nc.content = ClipContent::Audio { source: src, offset: 0 };
nc.length = i64::try_from(frames).unwrap_or(nc.length);
nc.clamp_fades();
// The render already has the clip's stretch applied; keeping it would stretch the new file again.
nc.stretch = 1.0;
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
@@ -227,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();
@@ -246,6 +261,21 @@ mod tests {
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
}
#[test]
fn shortening_process_clamps_fades_to_the_new_length() {
let (mut e, id) = gained_clip();
{
let c = e.session_mut().find_clip_mut(id).unwrap();
c.fade_in.len = 60;
c.fade_out.len = 40;
}
e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 0.25}, "clips": [id.0]})).unwrap();
let c = e.session().tracks[0].clips()[0].clone();
assert!(c.length < 64, "{}", c.length);
assert!(c.fade_in.len <= c.length, "fade in {} > length {}", c.fade_in.len, c.length);
assert_eq!(c.fade_out.len, 40.min(c.length));
}
#[test]
fn processed_clip_does_not_keep_the_stretch_it_baked() {
for stretch in [0.5, 2.0] {
+24 -2
View File
@@ -228,6 +228,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
nc.content = soundcraft_model::ClipContent::Audio { source: src, offset: 0 };
nc.length = out_len;
nc.stretch = 1.0;
nc.clamp_fades();
super::more_util::replace_clip(s, t, *id, nc);
rendered += 1;
}
@@ -246,7 +247,7 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
if let Some((src, off, len)) = orig {
c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
c.length = len.max(1);
c.fade_out.len = c.fade_out.len.min(c.length);
c.clamp_fades();
n += 1;
}
}
@@ -424,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, &[]);
@@ -496,6 +497,27 @@ mod tests {
}
}
#[test]
fn warping_and_unwarping_keep_fades_within_the_clip() {
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
if let Some(c) = e.session_mut().find_clip_mut(cid) {
c.fade_in.len = 20_000;
c.fade_out.len = 20_000;
}
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap();
let c = e.session().find_clip(cid).unwrap().1.clone();
assert!(c.fade_in.len <= c.length && c.fade_out.len <= c.length, "{} {} {}", c.length, c.fade_in.len, c.fade_out.len);
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap();
if let Some(c) = e.session_mut().find_clip_mut(cid) {
c.fade_in.len = 40_000;
}
e.execute("clip.remove_warp", &json!({"clips": [cid.0]})).unwrap();
let c = e.session().find_clip(cid).unwrap().1.clone();
assert!(c.fade_in.len <= c.length, "{} {}", c.length, c.fade_in.len);
}
#[test]
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
let mut e = crate::demo::demo_engine();
+15 -2
View File
@@ -196,8 +196,14 @@ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> {
fn effects_set(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let params = p.get("params").and_then(Value::as_object).ok_or_else(|| bad("clip.effects_set", "`params` must be an object of numbers"))?;
let vals: Vec<(String, f64)> =
params.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))).collect();
let vals: Vec<(String, f64)> = params
.iter()
.filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x)))
.map(|(k, v)| {
let v = if k == "gain" { v.clamp(f64::from(soundcraft_dsp::MIN_DB), f64::from(soundcraft_mix::CLIP_FX_MAX_GAIN_DB)) } else { v };
(k, v)
})
.collect();
let s = e.session_mut();
for c in &ids {
for (k, v) in &vals {
@@ -316,6 +322,13 @@ mod tests {
(Engine::new(s), t, ids)
}
#[test]
fn effects_set_caps_gain() {
let (mut e, _t, ids) = engine_with(&[(0, 100, 0)]);
e.execute("clip.effects_set", &json!({"clip": ids[0].0, "params": {"gain": 500.0}})).unwrap();
assert_eq!(e.session().edit.values.get(&format!("clip_fx.{}.gain", ids[0].0)).copied(), Some(24.0));
}
#[test]
fn unloop_removes_copies() {
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100, 100, 0), (200, 100, 0), (300, 100, 50)]);
+23 -55
View File
@@ -1,7 +1,7 @@
//! Edit menu commands.
use super::{
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection,
has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, has_clipboard, has_range, has_selection, has_tracks,
i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
};
use soundcraft_time::Samples;
@@ -125,15 +125,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_range,
strip_silence
),
cmd!(
query "edit.strip_silence_preview",
"Strip Silence Rectangles",
[],
None,
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
has_range,
strip_silence_preview
),
cmd!(
"edit.fades_create",
"Create...",
@@ -310,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;
@@ -337,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")?;
@@ -344,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;
@@ -385,13 +379,7 @@ fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let mut n = 0;
if range.is_empty() && !ids.is_empty() {
for t in &mut s.tracks {
if let Some(pl) = t.playlist_mut() {
let before = pl.clips.len();
pl.clips.retain(|c| !ids.contains(&c.id));
n += before - pl.clips.len();
}
}
n = ops::remove_clips(s, &ids, sh);
s.edit.selected_clips.clear();
} else {
for t in &tracks {
@@ -683,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 {
@@ -821,38 +814,8 @@ fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"tracks": tracks.len()}))
}
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
/// command in errors.
fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
let tracks = tracks_required(e, cmd, p)?;
let r = range_param(e, cmd, p)?;
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
let sr = e.session().sample_rate;
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
}
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
/// can be started.
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
}
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
let found = strip_silence_scan(e, p)?.run()?;
let tracks: Vec<Value> =
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
Ok(json!({"tracks": tracks}))
}
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
let (tracks, r, thr, min_len, (pre, post)) = strip_settings(e, "edit.strip_silence", p)?;
let (tracks, r, thr, min_len, (pre, post)) = super::strip_silence::strip_settings(e, "edit.strip_silence", p)?;
let mode = match p.get("mode") {
None => crate::io::StripMode::Strip,
Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?,
@@ -1001,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() {
@@ -1023,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> {
+79 -4
View File
@@ -115,11 +115,13 @@ fn tempo_constant(e: &mut Engine, p: &Value) -> Result<Value> {
let r = range_param(e, id, p)?;
let s = e.session();
let (t0, t1) = (s.tempo.samples_to_ticks(r.start, s.sample_rate), s.tempo.samples_to_ticks(r.end, s.sample_rate));
if t1 <= t0 {
return tempo(e, p);
}
let bpm = f64_or(p, "bpm", f64::NAN);
let s = e.session_mut();
if t1 <= t0 {
// An empty range sets the tempo at its start, which may come from the selection.
s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?;
return Ok(json!({"tick": t0, "bpm": bpm}));
}
// Read before the edit: the end is a musical position, and the new tempo moves it in samples.
let after = s.tempo.tempo_at_tick(t1);
let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1);
@@ -163,6 +165,13 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
if m.start >= r.start {
m.start += r.len();
m.end += r.len();
} else if m.end > r.start {
m.end += r.len();
}
}
for (at, _) in &mut s.key_signatures {
if *at >= r.start {
*at = at.saturating_add(r.len());
}
}
Ok(json!({"inserted": r.len()}))
@@ -190,6 +199,16 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
if m.start >= r.end {
m.start -= r.len();
m.end -= r.len();
} else if m.end >= r.end {
m.end -= r.len();
} else if m.end > r.start {
m.end = r.start;
}
}
s.key_signatures.retain(|(at, _)| !r.contains(*at));
for (at, _) in &mut s.key_signatures {
if *at >= r.end {
*at -= r.len();
}
}
Ok(json!({"removed": r.len()}))
@@ -302,7 +321,10 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result<Value> {
(None, Some(a), Some(b)) => (a, b),
(None, Some(p), None) | (None, None, Some(p)) => (p, p),
_ if kind == MarkerKind::Selection => (sel.start, sel.end),
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
_ => {
let point = if e.transport.playing { e.transport.position } else { sel.start };
(point, point)
}
};
let s = e.session_mut();
let n = s.markers.len() + 1;
@@ -407,6 +429,46 @@ mod tests {
use crate::demo::demo_engine;
use serde_json::json;
#[test]
fn tempo_constant_with_an_empty_range_sets_the_tempo_at_its_start() {
use soundcraft_time::Range;
let mut e = demo_engine();
e.session_mut().edit.selection = Range::point(96_000);
let r = e.execute("event.tempo_constant", &json!({"bpm": 90.0, "end": 96_000})).unwrap();
let s = e.session();
let tick = s.tempo.samples_to_ticks(96_000, s.sample_rate);
assert_eq!(r["tick"], tick);
assert_eq!(s.tempo.tempo_at_tick(tick), 90.0);
}
/// `start` is optional for insert/cut time: it defaults to the selection start, then `end`/`length` apply.
#[test]
fn insert_and_cut_time_without_start_use_selection_start() {
use soundcraft_time::Range;
let mut e = demo_engine();
let a = e.session().edit.selection.start;
let run = |e: &mut crate::Engine, sel: Range, id: &str, params: serde_json::Value| {
e.session_mut().edit.selection = sel;
e.execute(id, &params).unwrap()
};
// Point selection.
assert_eq!(run(&mut e, Range::point(a), "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"length": 700}))["removed"], 700);
// A non-empty selection must not override the given `end`/`length`.
let sel = Range::new(a, a + 100);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"end": a + 2000}))["inserted"], 2000);
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
// A null `start` takes the same path.
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"start": null, "end": a + 3000}))["removed"], 3000);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": null, "length": 900}))["inserted"], 900);
// An explicit `start` still wins over the selection.
let r = run(&mut e, sel, "event.cut_time", json!({"start": a + 1000, "end": a + 3000}));
assert_eq!(r["removed"], 2000);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": a + 1000, "length": 500}))["inserted"], 500);
}
/// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position.
#[test]
fn tempo_change_at_position_is_applied() {
@@ -473,6 +535,19 @@ mod tests {
assert_eq!(find(&c), Some((1000, 2000)));
}
/// A plain marker added during playback is a point at the playhead, whatever the edit selection is.
#[test]
fn markers_add_while_playing_is_a_point_at_the_playhead() {
let mut e = demo_engine();
e.session_mut().edit.selection.start = 48_000;
e.session_mut().edit.selection.end = 48_000;
e.transport.playing = true;
e.transport.position = 4800;
let r = e.execute("markers.add", &json!({"name": "M"})).unwrap();
let m = e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end));
assert_eq!(m, Some((4800, 4800)));
}
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
#[test]
fn edit_marker_sets_documented_color() {
+15 -1
View File
@@ -274,7 +274,12 @@ fn tempo_curve(e: &mut Engine, p: &Value, curve: Curve, id: &str) -> Result<Valu
fn tempo_stretch(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "event.tempo_stretch";
let (r, t0, t1) = selection_ticks(e, p, id)?;
// Here `length` is the new length of the selection, not part of the range.
let mut range_params = p.clone();
if let Some(m) = range_params.as_object_mut() {
m.remove("length");
}
let (r, t0, t1) = selection_ticks(e, &range_params, id)?;
let factor = match position_param(e, id, p, "length")? {
Some(l) if l > 0 => l as f64 / r.len() as f64,
_ => f64_or(p, "factor", f64::NAN),
@@ -901,6 +906,15 @@ mod tests {
}
}
/// For `event.tempo_stretch`, `length` is the new length of the selection, so the selection stays the range.
#[test]
fn tempo_stretch_length_keeps_selection_range() {
let mut e = Engine::default();
e.session_mut().edit.selection = Range::new(0, 96_000);
e.execute("event.tempo_stretch", &json!({"length": 192_000})).unwrap();
assert_eq!(e.session().tempo.tempo_at_tick(0), 60.0);
}
#[test]
fn tempo_curves() {
let mut e = Engine::default();
+7 -1
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 {
@@ -335,7 +337,11 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
return Err(bad(id, "no MIDI or instrument tracks to notate"));
}
let setup = crate::score::ScoreSetup::from_session(e.session());
let (text, ext) = if svg { (crate::score::svg(&parts, &setup), "svg") } else { (crate::score::musicxml(&parts, &setup), "musicxml") };
let (text, ext) = if svg {
(crate::score::svg(&parts, &setup).map_err(|m| bad(id, m))?, "svg")
} else {
(crate::score::musicxml(&parts, &setup).map_err(|m| bad(id, m))?, "musicxml")
};
let path = match str_param(p, "path") {
Some(p) => p.to_string(),
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
+36
View File
@@ -124,6 +124,12 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
let dt = i64_or(p, "ticks", 0);
let ds = i64_or(p, "semitones", 0);
let seq = seq_mut(e, id, "midi.notes_move")?;
// Clamp the shared delta so the earliest selected note stops at tick 0 and the selection keeps its spacing.
let earliest = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| n.start).min();
let dt = match earliest {
Some(m) => dt.max(0i64.saturating_sub(m).min(0)),
None => dt,
};
for (i, n) in seq.notes.iter_mut().enumerate() {
if idx.contains(&i) {
n.start = n.start.saturating_add(dt).max(0);
@@ -168,6 +174,36 @@ mod tests {
assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());
}
#[test]
fn notes_move_left_past_zero_keeps_spacing() {
let mut e = Engine::default();
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap();
let clip = r["clip"].as_u64().unwrap();
for s in [100, 200, 300] {
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();
}
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": -150})).unwrap();
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();
assert_eq!(starts, [0, 100, 300]);
}
#[test]
fn notes_move_without_left_overflow_does_not_shift_selection() {
let mut e = Engine::default();
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap();
for s in [0, 100] {
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();
}
seq_mut(&mut e, ClipId(clip), "test").unwrap().notes[0].start = -50;
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": 0})).unwrap();
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();
assert_eq!(starts, [0, 100]);
}
#[test]
fn notes_move_huge_params_do_not_overflow() {
let mut e = Engine::default();
+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);
}
+42
View File
@@ -155,6 +155,14 @@ pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples)
if !(0.1..=10.0).contains(&ratio) {
return Err(bad(cmd, format!("stretch ratio {ratio:.3} is outside 0.1..10")));
}
// The source span read in can be 10× the output, and both are held for every channel: bound
// each like any other offline render (a session's clip length and stretch are not trusted).
let channels = audio.buffer.channels.len().max(1);
let limit = crate::io::render_limit(channels);
if src_len > limit as f64 || usize::try_from(out_len).map_or(true, |n| n > limit) {
let secs = limit as f64 / s.sample_rate.as_f64();
return Err(bad(cmd, format!("the clip is too long to stretch: one render holds at most {secs:.0} s of {channels}-channel audio")));
}
let a = usize::try_from(offset.max(0)).unwrap_or(0);
let n = src_len as usize;
let ch: Vec<Vec<f32>> = audio
@@ -213,4 +221,38 @@ mod tests {
s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap();
assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples);
}
/// A stereo session with a one-frame source and one clip on it.
fn stretched_clip(length: Samples, stretch: f64) -> (Session, Clip) {
use soundcraft_model::{ChannelFormat, TrackKind};
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Long"));
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5]; 2] };
let src = crate::io::add_source(&mut s, "One", buf, None, FileFormat::Wav);
let cid = s.new_clip_id();
let mut clip = Clip::audio(cid, "Clip", src, 0, 0, length);
clip.stretch = stretch;
crate::edit::place_clip(&mut s, t, clip.clone());
(s, clip)
}
/// #895: a session's clip length and stretch made the source span read in 10× the one-hour
/// output limit, for every channel (about 14 GB here), and the allocation aborted the app.
#[test]
fn a_stretch_over_the_render_limit_is_refused_before_allocating() {
let hour = Session::default().sample_rate.samples(3600.0);
let (mut s, clip) = stretched_clip(hour, 0.1);
let err = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap_err();
assert!(err.to_string().contains("too long to stretch"), "{err}");
let mut e = crate::Engine::new(s);
assert!(e.execute("clip.remove_pitch_shift", &serde_json::json!({"clips": [clip.id.0]})).is_err());
let (_, kept) = e.session().find_clip(clip.id).unwrap();
assert_eq!((kept.stretch, kept.length), (0.1, hour), "the clip is left as it was");
// An ordinary stretch still renders, at the length asked for.
let (mut s, clip) = stretched_clip(1_000, 0.5);
let src = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap().unwrap();
assert!(s.pool.get(src).unwrap().buffer.channels.iter().all(|c| c.len() == 1_000));
}
}
+133 -17
View File
@@ -42,13 +42,24 @@ pub fn specs() -> Vec<CommandSpec> {
always,
prepare_dpe
),
cmd!(noundo "setup.hardware", "Hardware...", ["Setup"], None, "{device?, buffer_size?: 16..8192} — audio interface settings (sample rate is the session's)", always, |e, p| engine_settings(e, p)),
cmd!(noundo "setup.playback_engine", "Playback Engine...", ["Setup"], None, "{device?, buffer_size?: 16..8192, delay_compensation?: bool}", always, |e, p| {
if let Some(v) = p.get("delay_compensation").and_then(Value::as_bool) {
e.session_mut().edit.delay_compensation = v;
}
engine_settings(e, p)
}),
cmd!(
noundo "setup.hardware",
"Hardware...",
["Setup"],
None,
"{device?: output name, buffer_size?: 16..=8192 or null} — output device and buffer. Applied only in the desktop app (Playback Engine window / ui.audio_device), which returns the open device. Headless stores the request and returns applied: false; buffer_size is null when unset. Any integer in range, not rounded to the window's power-of-two sizes. Sample rate is the session's",
always,
|e, p| engine_settings(e, p, "setup.hardware")
),
cmd!(
noundo "setup.playback_engine",
"Playback Engine...",
["Setup"],
None,
"{device?: output name, buffer_size?: 16..=8192 or null, delay_compensation?: bool} — device and buffer as setup.hardware (applied only in the desktop app, or via ui.audio_device); delay_compensation is applied here too. Headless returns applied: false and does not invent a buffer",
always,
|e, p| engine_settings(e, p, "setup.playback_engine")
),
cmd!(noundo "setup.disk_allocation", "Disk Allocation...", ["Setup"], None, "{path?, track?} — the record folder for the session, or for one track", always, disk_allocation),
cmd!(noundo "setup.peripherals", "Peripherals...", ["Setup"], None, "{settings?: {name: number|bool|string}} — control surface / sync device settings", always, peripherals),
cmd!(
@@ -156,18 +167,69 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"prepared": n}))
}
fn engine_settings(e: &mut Engine, p: &Value) -> Result<Value> {
/// A `buffer_size` argument: absent, cleared (`null`), or an integer in 16..=8192.
enum BufferParam {
Skip,
Clear,
Set(u64),
}
/// Same range as `ui.audio_device`. Out of range and non-numbers error; there is no clamp and no
/// power-of-two rounding (the Playback Engine window lists those sizes, the command keeps the number).
fn buffer_param(id: &str, p: &Value) -> Result<BufferParam> {
match p.get("buffer_size") {
None => Ok(BufferParam::Skip),
Some(Value::Null) => Ok(BufferParam::Clear),
Some(v) => {
let n = v.as_u64().filter(|n| (16..=8192).contains(n)).ok_or_else(|| bad(id, "`buffer_size` must be a number in 16..=8192 or null"))?;
Ok(BufferParam::Set(n))
}
}
}
/// `device` as `ui.audio_device`'s `output`: a name, or `null`/empty to clear. Absent leaves it.
fn device_param<'a>(id: &str, p: &'a Value) -> Result<Option<Option<&'a str>>> {
match p.get("device") {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(name)) => Ok(Some((!name.is_empty()).then_some(name.as_str()))),
Some(_) => Err(bad(id, "`device` must be a string or null")),
}
}
/// Headless `setup.hardware` / `setup.playback_engine`. Playback never reads `engine.device` or
/// `engine.buffer_size` (only this command does); they are kept so a later call can report the
/// request. The result says `applied: false` and leaves `buffer_size` null until one was stored.
fn engine_settings(e: &mut Engine, p: &Value, id: &str) -> Result<Value> {
let buffer = buffer_param(id, p)?;
let device = device_param(id, p)?;
if id == "setup.playback_engine"
&& let Some(on) = p.get("delay_compensation").and_then(Value::as_bool)
{
e.session_mut().edit.delay_compensation = on;
}
let s = e.session_mut();
if let Some(b) = p.get("buffer_size").and_then(Value::as_u64) {
let b = b.clamp(16, 8192).next_power_of_two().min(8192);
s.edit.values.insert("engine.buffer_size".into(), b as f64);
match buffer {
BufferParam::Skip => {}
BufferParam::Clear => {
s.edit.values.remove("engine.buffer_size");
}
BufferParam::Set(n) => {
s.edit.values.insert("engine.buffer_size".into(), n as f64);
}
}
if let Some(d) = str_param(p, "device") {
set_text(&mut s.edit, "engine.device", Some(d));
if let Some(name) = device {
set_text(&mut s.edit, "engine.device", name);
}
// Exact integers only: a hand-edited fraction is not reported as a buffer size.
let buffer_size = s.edit.values.get("engine.buffer_size").copied().and_then(|v| {
let n = v as u64;
(v.is_finite() && (n as f64) == v).then_some(n)
});
Ok(json!({
"applied": false,
"device": text(&s.edit, "engine.device"),
"buffer_size": s.edit.value("engine.buffer_size", 1024.0),
"buffer_size": buffer_size,
"sample_rate": s.sample_rate.hz(),
"bit_depth": s.bit_depth,
"delay_compensation": s.edit.delay_compensation,
@@ -307,7 +369,7 @@ fn current_timecode(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let at = s.edit.selection.start.max(0);
let abs = tc.to_samples(s.frame_rate, s.sample_rate);
s.timecode_start = abs - at;
s.timecode_start = abs.saturating_sub(at).clamp(-soundcraft_time::MAX_POSITION, soundcraft_time::MAX_POSITION);
Ok(json!({"timecode_start": s.timecode_start, "at": at}))
}
@@ -410,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());
@@ -567,6 +629,20 @@ mod tests {
assert_eq!(shown, "01:00:00:00");
}
#[test]
fn current_timecode_out_of_range_is_clamped() {
use soundcraft_time::MAX_POSITION;
for tc in ["-9223372036854775807:00:00:00", "9999999:00:00:00"] {
let mut e = Engine::default();
e.execute("edit.select", &json!({"start": 48_000})).unwrap();
e.execute("setup.current_timecode", &json!({"timecode": tc})).unwrap();
let start = e.session().timecode_start;
assert!((-MAX_POSITION..=MAX_POSITION).contains(&start), "{tc}: start {start}");
let reloaded = Session::from_json(&e.session().to_json().unwrap()).unwrap();
assert_eq!(reloaded.timecode_start, start, "{tc}");
}
}
#[test]
fn click_countoff_settings() {
let mut e = Engine::default();
@@ -587,7 +663,8 @@ mod tests {
fn engine_and_midi_settings() {
let mut e = Engine::default();
let r = e.execute("setup.playback_engine", &json!({"buffer_size": 300, "device": "Interface"})).unwrap();
assert_eq!(r["buffer_size"], 512.0);
assert_eq!(r["applied"], false);
assert_eq!(r["buffer_size"], 300);
assert_eq!(r["device"], "Interface");
let r = e.execute("setup.midi_input_filter", &json!({"filter": ["sysex", "bogus"]})).unwrap();
assert_eq!(r["filtered"], json!(["sysex"]));
@@ -597,6 +674,45 @@ mod tests {
assert_eq!(r["samples"], 3200.0);
}
#[test]
fn playback_engine_does_not_pretend_the_device_changed() {
let mut e = Engine::default();
let r = e.execute("setup.playback_engine", &json!({})).unwrap();
assert_eq!(r["applied"], false);
assert!(r["buffer_size"].is_null(), "no invented default buffer: {r}");
assert!(r["device"].is_null());
assert_eq!(r["delay_compensation"], true, "the session default, not a device claim");
let r = e.execute("setup.hardware", &json!({"buffer_size": 300, "device": "Interface"})).unwrap();
assert_eq!(r["applied"], false);
assert_eq!(r["buffer_size"], 300, "kept, not rounded to a power of two");
assert_eq!(r["device"], "Interface");
assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0);
let r = e.execute("setup.playback_engine", &json!({"delay_compensation": false})).unwrap();
assert_eq!(r["applied"], false);
assert_eq!(r["delay_compensation"], false);
assert_eq!(r["buffer_size"], 300, "delay compensation does not clear a stored buffer");
assert!(!e.session().edit.delay_compensation);
let err = e.execute("setup.playback_engine", &json!({"buffer_size": 8, "delay_compensation": true, "device": "Other"})).unwrap_err();
assert!(err.to_string().contains("16..=8192"), "{err}");
assert!(!e.session().edit.delay_compensation, "an invalid buffer changes nothing");
assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0);
assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface"));
assert!(e.execute("setup.hardware", &json!({"buffer_size": "big"})).is_err());
let err = e.execute("setup.playback_engine", &json!({"device": 42, "delay_compensation": true})).unwrap_err();
assert!(err.to_string().contains("`device` must be a string or null"), "{err}");
assert!(!e.session().edit.delay_compensation, "an invalid device changes nothing");
assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface"));
let r = e.execute("setup.hardware", &json!({"buffer_size": null, "device": ""})).unwrap();
assert_eq!(r["applied"], false);
assert!(r["buffer_size"].is_null());
assert!(r["device"].is_null());
}
#[test]
fn import_session_data_brings_tracks_and_audio() {
let dir = std::env::temp_dir().join(format!("sc-import-{}", std::process::id()));
+49
View File
@@ -0,0 +1,49 @@
//! Strip Silence Rectangles: the ranges Strip Silence keeps, which the Strip Silence window draws.
use super::{CommandSpec, f32_or, f64_or, has_range, range_param, tracks_required};
use soundcraft_model::TrackId;
use soundcraft_time::Samples;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
query "edit.strip_silence_preview",
"Strip Silence Rectangles",
[],
None,
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
has_range,
strip_silence_preview
)]
}
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
pub(super) type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
/// command in errors.
pub(super) fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
let tracks = tracks_required(e, cmd, p)?;
let r = range_param(e, cmd, p)?;
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
let sr = e.session().sample_rate;
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
}
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
/// can be started.
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
}
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
let found = strip_silence_scan(e, p)?.run()?;
let tracks: Vec<Value> =
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
Ok(json!({"tracks": tracks}))
}
+74 -6
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> {
@@ -590,6 +620,20 @@ fn owned_folder_bus(s: &Session, folder: TrackId) -> Option<soundcraft_model::Bu
}
}
/// Whether a bus is still routed through by a track that survives deleting
/// `deleted`, ignoring the output slots of `folder`'s own members, which the
/// delete reroutes to the parent folder's bus. A folder owns its input bus
/// only when this is false: `track.input` or a session file can point a
/// folder at a bus that other tracks already use (#914).
fn bus_used_beyond_folder(s: &Session, bus: soundcraft_model::BusId, deleted: &[TrackId], folder: TrackId) -> bool {
let route = Route::Bus(bus);
s.tracks.iter().filter(|track| !deleted.contains(&track.id)).any(|track| {
track.mixer.input == route
|| track.mixer.sends.iter().flatten().any(|send| send.target == route)
|| (track.mixer.output == route && !in_folder(s, track.id, folder))
})
}
/// Keep only the selected roots so selecting a folder and one of its children preserves the subtree.
fn root_tracks(s: &Session, tracks: Vec<TrackId>) -> Vec<TrackId> {
tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect()
@@ -708,7 +752,11 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
.filter_map(|folder| {
let bus = owned_folder_bus(s, *folder)?;
let parent = surviving_parent(s, &tracks, *folder);
Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main)))
let replacement = parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main);
// A bus that surviving tracks route through is shared, not the
// folder's own: keep it, and every route to it, when the folder
// goes (#914).
(!bus_used_beyond_folder(s, bus, &tracks, *folder)).then_some((bus, replacement))
})
.collect();
// Promote children of deleted folders and repair only routes that used the deleted
@@ -1129,6 +1177,26 @@ mod tests {
assert!(e.session().bus(outer_bus).is_some(), "the surviving parent folder's bus must remain");
}
#[test]
fn deleting_a_folder_keeps_a_bus_other_tracks_still_use() {
let mut e = crate::demo::demo_engine();
// #914: the demo session's Verb bus is shared: an aux takes it as its input
// and several tracks send to it. Point a new folder's input at it, the way
// `track.input` or a session file can, then delete the folder.
let verb = e.session().bus_by_name("Verb").unwrap().id;
let folder = e.execute("track.new", &json!({"kind": "folder", "name": "Shared"})).unwrap();
let folder = TrackId(folder["tracks"][0].as_u64().unwrap());
e.execute("track.input", &json!({"tracks": [folder.0], "input": "Verb"})).unwrap();
assert_eq!(e.session().track(folder).unwrap().mixer.input, Route::Bus(verb));
e.execute("track.delete", &json!({"tracks": [folder.0]})).unwrap();
assert!(e.session().track(folder).is_none(), "the folder itself is deleted");
assert!(e.session().bus(verb).is_some(), "a bus other tracks route through must survive");
let aux = e.session().track_by_name("Verb").unwrap();
assert_eq!(aux.mixer.input, Route::Bus(verb), "the aux input must keep the bus");
let snare = e.session().track_by_name("Snare").unwrap();
assert!(snare.mixer.sends.iter().flatten().any(|send| send.target == Route::Bus(verb)), "sends to the shared bus must survive");
}
#[test]
fn heights_take_points_and_step_through_the_presets() {
let mut e = crate::demo::demo_engine();
+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);
}
+3 -2
View File
@@ -9,9 +9,10 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "transport.play", "Play", [], Some("Space"), "{from?: position}", always, |e, p| {
if let Some(at) = position_param(e, "transport.play", p, "from")? {
e.transport_requests.push(TransportRequest::Locate(at.max(0)));
e.transport_requests.push(TransportRequest::PlayFrom(at.max(0)));
} else {
e.transport_requests.push(TransportRequest::Play);
}
e.transport_requests.push(TransportRequest::Play);
Ok(json!({}))
}),
cmd!(noundo "transport.stop", "Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Stop); Ok(json!({})) }),
+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));
}
}
+30 -2
View File
@@ -1,4 +1,5 @@
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before.
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. Cancel Zoom Toggle turns it off
//! and keeps the current view.
use super::{CommandSpec, always, f64_or};
use soundcraft_model::{TrackHeight, ZoomToggle};
use soundcraft_time::MAX_POSITION;
@@ -7,7 +8,16 @@ use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)]
vec![
cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle),
cmd!(noundo "view.zoom_toggle_cancel", "Cancel Zoom Toggle", [], Some("Alt+Shift+E"), "{}", always, cancel),
]
}
/// Forget the view Zoom Toggle would go back to, leaving the zoom, scroll and track heights as they are.
fn cancel(e: &mut Engine, _: &Value) -> Result<Value> {
let was_on = e.session_mut().edit.zoom_toggle.take().is_some();
Ok(json!({"on": false, "canceled": was_on}))
}
fn toggle(e: &mut Engine, p: &Value) -> Result<Value> {
@@ -73,6 +83,24 @@ mod tests {
assert_eq!(view(&e), (500.0, 1000, vec![Medium, Fit(299), Medium]));
}
#[test]
fn cancel_keeps_the_zoomed_view_and_the_next_toggle_zooms_in_again() {
let mut e = engine(json!({"tracks": ["Audio 1", "Audio 3"], "start": 48_000, "end": 96_000}));
e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap();
let zoomed = view(&e);
assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": true}));
assert_eq!(e.session().edit.zoom_toggle, None);
assert_eq!(view(&e), zoomed, "cancel keeps the view Zoom Toggle zoomed in to");
assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": false}));
assert_eq!(view(&e), zoomed);
e.execute("view.zoom_set", &json!({"samples_per_px": 200.0})).unwrap();
assert_eq!(e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(), json!({"on": true}));
e.execute("view.zoom_toggle", &json!({})).unwrap();
assert_eq!(view(&e), (200.0, 48_000, vec![Fit(249), Medium, Fit(249)]), "the toggle goes back to the view it saw after the cancel");
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "view.zoom_toggle_cancel").unwrap();
assert_eq!(spec.shortcut, Some("Alt+Shift+E"));
}
#[test]
fn hostile_sizes_and_stored_views_stay_in_range() {
let mut e = engine(json!({"tracks": ["Audio 1"], "start": 0, "end": MAX_POSITION}));
+386
View File
@@ -0,0 +1,386 @@
//! Borrowed document equality for the saved-state indicator.
use soundcraft_model::{EditState, Mixer, Session, Track};
/// 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 {
format_version,
name,
sample_rate,
bit_depth,
frame_rate,
timecode_start,
tempo,
key_signatures,
tracks: _,
sources,
videos,
busses,
outputs,
markers,
groups,
auto_names,
edit,
next_id,
comments,
midi_originals,
pool,
folder,
} = 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, 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,
edit_mode,
selection: _,
selected_tracks: _,
selected_clips: _,
selected_sources,
playhead: _,
grid,
nudge,
grid_lines,
grid_follow_main,
nudge_follow_main,
grid_clips_markers,
main_counter,
sub_counter,
zoom: _,
zoom_toggle,
rulers,
link_timeline_edit,
link_track_edit,
insertion_follows_playback,
loop_playback,
pre_post_roll,
pre_roll,
post_roll,
tab_to_transient,
automation_follows_edit,
markers_follow_edit,
layered_editing,
mirrored_midi,
click,
countoff,
countoff_bars,
midi_merge,
record_mode,
loop_record,
quickpunch,
scrolling,
solo_mode,
pre_fader_metering,
delay_compensation,
keyboard_focus,
timeline_selection: _,
flags,
values,
} = edit;
// Borrow the saved navigation on both sides instead of overwriting a document clone.
// Keeping these values in the comparison also preserves PartialEq for non-finite zooms.
(
(
tool,
edit_mode,
&navigation.selection,
&navigation.selected_tracks,
&navigation.selected_clips,
selected_sources,
&navigation.playhead,
grid,
nudge,
grid_lines,
main_counter,
sub_counter,
),
(
&navigation.zoom,
zoom_toggle,
rulers,
link_timeline_edit,
link_track_edit,
insertion_follows_playback,
loop_playback,
pre_post_roll,
pre_roll,
post_roll,
tab_to_transient,
automation_follows_edit,
),
(
markers_follow_edit,
layered_editing,
mirrored_midi,
click,
countoff,
countoff_bars,
midi_merge,
record_mode,
loop_record,
quickpunch,
scrolling,
solo_mode,
),
(
grid_follow_main,
nudge_follow_main,
grid_clips_markers,
pre_fader_metering,
delay_compensation,
keyboard_focus,
&navigation.timeline_selection,
flags,
values,
),
)
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_model::{ChannelFormat, Clip, ClipId, SourceId, TrackId, TrackKind, ZoomToggle};
use soundcraft_time::{Range, TimeFormat};
use std::sync::Arc;
/// 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);
to.selection = from.selection;
to.timeline_selection = from.timeline_selection;
to.selected_tracks.clone_from(&from.selected_tracks);
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
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");
assert_eq!(same_document(saved, doc), previous_same_document(saved, doc), "{field}, previous policy");
}
#[test]
fn navigation_fields_are_ignored() {
let saved = Session::default();
let mut doc = saved.clone();
doc.edit.selection = Range::new(100, 200);
doc.edit.timeline_selection = Range::new(300, 400);
doc.edit.selected_tracks = vec![TrackId(50)];
doc.edit.selected_clips = vec![ClipId(60)];
doc.edit.playhead = 500;
doc.edit.zoom.samples_per_px = 64.0;
doc.edit.zoom.scroll = 600;
doc.edit.zoom.waveform_zoom = 2.0;
doc.edit.zoom.presets = [1.0; 5];
check(&saved, &doc, true, "navigation");
}
#[test]
fn persisted_edit_settings_still_change_document_equality() {
let saved = Session::default();
let edits: &[(&str, fn(&mut EditState))] = &[
("tool", |e| e.tool = soundcraft_model::Tool::Grabber),
("edit_mode", |e| e.edit_mode = soundcraft_model::EditMode::Grid),
("selected_sources", |e| e.selected_sources.push(SourceId(1))),
("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() })),
("rulers", |e| e.rulers.clear()),
("link_timeline_edit", |e| e.link_timeline_edit = !e.link_timeline_edit),
("link_track_edit", |e| e.link_track_edit = !e.link_track_edit),
("insertion_follows_playback", |e| e.insertion_follows_playback = !e.insertion_follows_playback),
("loop_playback", |e| e.loop_playback = !e.loop_playback),
("pre_post_roll", |e| e.pre_post_roll = !e.pre_post_roll),
("pre_roll", |e| e.pre_roll += 1),
("post_roll", |e| e.post_roll += 1),
("tab_to_transient", |e| e.tab_to_transient = !e.tab_to_transient),
("automation_follows_edit", |e| e.automation_follows_edit = !e.automation_follows_edit),
("markers_follow_edit", |e| e.markers_follow_edit = !e.markers_follow_edit),
("layered_editing", |e| e.layered_editing = !e.layered_editing),
("mirrored_midi", |e| e.mirrored_midi = !e.mirrored_midi),
("click", |e| e.click = !e.click),
("countoff", |e| e.countoff = !e.countoff),
("countoff_bars", |e| e.countoff_bars += 1),
("midi_merge", |e| e.midi_merge = !e.midi_merge),
("record_mode", |e| e.record_mode = "destructive".into()),
("loop_record", |e| e.loop_record = !e.loop_record),
("quickpunch", |e| e.quickpunch = !e.quickpunch),
("scrolling", |e| e.scrolling = "continuous".into()),
("solo_mode", |e| e.solo_mode = "afl".into()),
("pre_fader_metering", |e| e.pre_fader_metering = !e.pre_fader_metering),
("delay_compensation", |e| e.delay_compensation = !e.delay_compensation),
("keyboard_focus", |e| e.keyboard_focus = "midi".into()),
("flags", |e| {
e.flags.insert("test.flag".into());
}),
("values", |e| {
e.values.insert("test.value".into(), 0.5);
}),
];
for &(field, edit) in edits {
let mut doc = saved.clone();
edit(&mut doc.edit);
check(&saved, &doc, false, field);
}
}
#[test]
fn track_and_media_changes_still_change_document_equality() {
let mut saved = Session::default();
let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let clip = Clip::audio(saved.new_clip_id(), "Clip", SourceId(900), 0, 0, 100);
saved.track_mut(track).unwrap().playlist_mut().unwrap().clips.push(clip);
let audio =
Arc::new(soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 100]] }));
saved.pool.insert(SourceId(900), audio);
check(&saved, &saved.clone(), true, "shared decoded audio");
let mut doc = saved.clone();
doc.track_mut(track).unwrap().playlist_mut().unwrap().clips[0].gain_db = -6.0;
check(&saved, &doc, false, "clip gain");
let mut doc = saved.clone();
doc.pool.remove(SourceId(900));
check(&saved, &doc, false, "decoded audio removed");
let mut doc = saved.clone();
doc.folder = Some(std::path::PathBuf::from("session"));
check(&saved, &doc, false, "session folder");
let mut doc = saved.clone();
doc.edit.values.insert("test.nan".into(), f64::NAN);
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();
let doc = saved.clone();
saved.edit.zoom.waveform_zoom = f32::NAN;
assert!(!same_document(&saved, &doc));
assert!(same_document(&doc, &saved));
assert_eq!(same_document(&saved, &doc), previous_same_document(&saved, &doc));
assert_eq!(same_document(&doc, &saved), previous_same_document(&doc, &saved));
}
}
+41 -23
View File
@@ -177,6 +177,26 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool)
removed
}
/// Remove the clips `ids` from every track. In Shuffle the later clips on each track close the holes the movable
/// ones leave, as a Shuffle move lifts them (see [`lift_plan`]), so a crossfade partner keeps its material. Returns
/// how many clips went.
pub fn remove_clips(s: &mut Session, ids: &[ClipId], shuffle: bool) -> usize {
let ids: HashSet<ClipId> = ids.iter().copied().collect();
let mut n = 0;
for track in s.tracks.iter().map(|t| t.id).collect::<Vec<_>>() {
if shuffle {
n += lift_all(s, track, &ids).len();
}
// Locked clips don't lift; they go without closing up, as everything does outside Shuffle.
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
let before = pl.clips.len();
pl.clips.retain(|c| !ids.contains(&c.id));
n += before - pl.clips.len();
}
}
n
}
/// Copy the material inside `range` (clips trimmed to the range), relative to `range.start`.
pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
let t = TimeCtx::of(s);
@@ -238,13 +258,16 @@ pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], lengt
make_room(s, track, at, length)?;
}
let mut ids = Vec::new();
let mut placed = Vec::new();
for c in clips {
let mut nc = c.clone();
nc.id = s.new_clip_id();
nc.start += at;
ids.push(nc.id);
place_clip(s, track, nc);
placed.push((track, nc));
}
// Clear every destination first, so pasted clips that overlap (crossfades) keep each other.
put_clips(s, placed);
Ok(ids)
}
@@ -505,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;
@@ -524,7 +553,7 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
}
let n = moving.len();
let placed = moving.into_iter().map(|(tid, mut c)| {
c.start = c.start.saturating_add(delta).max(0);
c.start = c.start.saturating_add(delta);
(lands(dest, tid), c)
});
put_clips(s, placed.collect());
@@ -547,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
@@ -765,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
@@ -794,8 +809,11 @@ pub fn trim_clip(s: &mut Session, id: ClipId, start: Option<Samples>, end: Optio
lo = lo.max(0);
}
let file = |c: &Clip| {
let start = c.start.saturating_sub(c.source_offset());
material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(n).max(c.end())))
// The mixer plays from `offset + rel / stretch`, so file samples span `stretch` times as many timeline samples.
let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 };
let scale = |n: Samples| if (stretch - 1.0).abs() >= 1e-9 { (n as f64 * stretch).round() as Samples } else { n };
let start = c.start.saturating_sub(scale(c.source_offset()));
material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(scale(n)).max(c.end())))
};
let t = TimeCtx::of(s);
let mut c = old.clone();
+94 -3
View File
@@ -159,6 +159,40 @@ fn clear_slip_and_shuffle() {
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 2000, 3000)]);
}
#[test]
fn remove_clips_closes_each_gap_in_shuffle_only() {
let split = |shuffle| {
let (mut s, t) = session_with_clip();
separate_at(&mut s, t, 2000);
separate_at(&mut s, t, 3000);
separate_at(&mut s, t, 4000);
let ids: Vec<ClipId> = s.track(t).unwrap().clips().iter().map(|c| c.id).collect();
// The first and third pieces: [1000, 2000) and [3000, 4000).
assert_eq!(remove_clips(&mut s, &[ids[0], ids[2]], shuffle), 2);
clips(&s, t)
};
assert_eq!(split(false), vec![(2000, 1000, 2000), (4000, 1000, 4000)]);
assert_eq!(split(true), vec![(1000, 1000, 2000), (2000, 1000, 4000)]);
}
#[test]
fn remove_clips_in_shuffle_keeps_crossfade_partners_apart() {
// `(start, end)` of each clip; the second one goes.
let remove_second = |spans: &[(Samples, Samples)]| {
let (mut s, t) = session_with_clip();
let ids: Vec<ClipId> = spans.iter().map(|_| s.new_clip_id()).collect();
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips = ids.iter().zip(spans).map(|(id, (a, b))| Clip::audio(*id, "c", SourceId(500), *a, *a, b - a)).collect();
}
assert_eq!(remove_clips(&mut s, &[ids[1]], true), 1);
clips(&s, t).into_iter().map(|(start, len, _)| (start, start + len)).collect::<Vec<_>>()
};
// Crossfaded with the clip before: the hole starts where that one ends.
assert_eq!(remove_second(&[(0, 1050), (950, 2000), (2000, 3000)]), vec![(0, 1050), (1050, 2050)]);
// Crossfaded with the clip after: the hole ends where that one starts.
assert_eq!(remove_second(&[(0, 1000), (1000, 2050), (1950, 3000), (4000, 5000)]), vec![(0, 1000), (1000, 2050), (3050, 4050)]);
}
#[test]
fn copy_paste_overwrites() {
let (mut s, t) = session_with_clip();
@@ -296,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]
@@ -343,3 +377,60 @@ fn delete_fades_leaves_unrelated_overlaps_alone() {
let c = s.track(t).unwrap().clips();
assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100));
}
#[test]
fn paste_keeps_crossfade_overlap() {
let (mut s, t) = session_with_clip();
let (a, b) = (s.new_clip_id(), s.new_clip_id());
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips.clear();
let mut ca = Clip::audio(a, "a", SourceId(500), 0, 1000, 2000);
ca.fade_out = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 };
let mut cb = Clip::audio(b, "b", SourceId(500), 0, 2500, 2500);
cb.fade_in = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 };
pl.clips.push(ca);
pl.clips.push(cb);
pl.sort();
}
let copied = copy_range(&s, t, Range::new(0, 6000));
paste(&mut s, t, 20_000, &copied, 6000, false).unwrap();
let pasted: Vec<_> =
s.track(t).unwrap().clips().iter().filter(|c| c.start >= 20_000).map(|c| (c.start, c.length, c.fade_out.len, c.fade_in.len)).collect();
assert_eq!(pasted, vec![(21_000, 2000, 500, 0), (22_500, 2500, 0, 500)]);
}
#[test]
fn trim_clip_bounds_stretched_clip_to_its_file() {
for (stretch, length, want) in [(0.5, 5000, 5000), (2.0, 1000, 20_000)] {
let (mut s, t) = session_with_clip();
let id = {
let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap();
c.stretch = stretch;
c.start = 0;
c.length = length;
if let ClipContent::Audio { offset, .. } = &mut c.content {
*offset = 0;
}
c.id
};
let c = trim_clip(&mut s, id, None, Some(99_999), false).unwrap();
assert_eq!(c.end(), want, "stretch {stretch}");
}
}
#[test]
fn trim_clip_start_bound_scales_the_offset_by_stretch() {
let (mut s, t) = session_with_clip();
let id = {
let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap();
c.stretch = 2.0;
c.start = 10_000;
c.length = 1000;
if let ClipContent::Audio { offset, .. } = &mut c.content {
*offset = 1000;
}
c.id
};
let c = trim_clip(&mut s, id, Some(0), None, false).unwrap();
assert_eq!(c.start, 8000);
}
+14 -1
View File
@@ -115,7 +115,7 @@ pub fn session_text(e: &Engine) -> String {
out.push_str(&format!("SESSION NAME:\t{}\nSAMPLE RATE:\t{}\nBIT DEPTH:\t{:?}\nTIMECODE FORMAT:\t{}\n# OF AUDIO TRACKS:\t{}\n# OF AUDIO CLIPS:\t{}\n# OF AUDIO FILES:\t{}\n\n",
s.name, s.sample_rate.hz(), s.bit_depth, s.frame_rate.label(),
s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).count(),
s.tracks.iter().map(|t| t.clips().len()).sum::<usize>(), s.sources.len()));
s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum::<usize>(), s.sources.len()));
out.push_str("F I L E S I N S E S S I O N\nFilename\tLocation\n");
for src in &s.sources {
out.push_str(&format!("{}\t{}\n", src.name, src.path));
@@ -156,6 +156,19 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn session_text_counts_only_audio_track_clips() {
let mut e = Engine::default();
let t = e.execute("track.new", &json!({"kind": "midi"})).unwrap()["tracks"][0].clone();
e.execute("midi.new_clip", &json!({"track": t, "start": 0, "length": 48000})).unwrap();
let text = session_text(&e);
assert!(text.contains("# OF AUDIO CLIPS:\t0\n"), "{text}");
let demo = crate::demo::demo_engine();
let n: usize = demo.session().tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum();
assert!(session_text(&demo).contains(&format!("# OF AUDIO CLIPS:\t{n}\n")));
}
#[test]
fn sources_report_whether_audio_is_loaded() {
let mut e = crate::demo::demo_engine();
+173 -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");
@@ -598,6 +743,7 @@ fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
let mut src = std::fs::File::open(from)?;
let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
if let Err(err) = std::io::copy(&mut src, &mut dst) {
#[cfg(not(target_arch = "wasm32"))]
drop(dst);
let _ = std::fs::remove_file(to);
return Err(err);
@@ -733,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)?;
@@ -745,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.
+42 -4
View File
@@ -2,17 +2,33 @@ use super::{ChannelFormat, Clip, ClipContent, Engine, EngineError, Result, Sampl
/// Most meter changes a MIDI file may bring into the tempo map; scores have a handful.
pub(super) const MAX_MIDI_METERS: usize = 10_000;
/// Most tempo changes a MIDI file may bring into the tempo map: conversions walk the tempo events.
pub(super) const MAX_MIDI_TEMPOS: usize = 10_000;
/// Most synth tracks a MIDI file may create in the session.
pub(super) const MAX_MIDI_TRACKS: usize = 512;
/// Import a Standard MIDI File as MIDI tracks (one per SMF track).
pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result<Vec<TrackId>> {
let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?;
let s = e.session_mut();
if tempo_map {
// Each meter is placed by walking the ones before it.
if smf.meters.len() > MAX_MIDI_METERS {
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_METERS} meter changes")));
}
if smf.tempos.len() > MAX_MIDI_TEMPOS {
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TEMPOS} tempo changes")));
}
}
let tracks_to_import = smf.tracks.iter().filter(|tr| !tr.sequence.notes.is_empty() || !tr.sequence.ctrls.is_empty()).count();
if tracks_to_import > MAX_MIDI_TRACKS {
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TRACKS} tracks")));
}
let s = e.session_mut();
if tempo_map {
for (tick, bpm) in &smf.tempos {
if s.tempo.tempo_at_tick(*tick) == *bpm {
continue;
}
let _ = s.tempo.set_tempo(*tick, *bpm);
}
for (tick, n, d) in &smf.meters {
@@ -66,15 +82,37 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
for c in t.clips() {
if let ClipContent::Midi { sequence } = &c.content {
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
for n in &sequence.notes {
// Only what plays: notes starting inside the clip, cut at its end.
let clip_end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
for n in sequence.notes.iter().filter(|n| n.start.saturating_add(base) < clip_end) {
let mut n = *n;
n.start = n.start.saturating_add(base);
n.length = n.length.min(clip_end - n.start).max(1);
seq.notes.push(n);
}
for ev in &sequence.ctrls {
let mut kept: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
for ev in sequence.ctrls.iter().filter(|ev| ev.tick.saturating_add(base) < clip_end) {
let mut ev = *ev;
ev.tick = ev.tick.saturating_add(base);
seq.ctrls.push(ev);
kept.push(ev);
}
// A sustain still held at the clip end is released there.
let mut release: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
for ev in &kept {
if ev.kind != soundcraft_midi::CtrlKind::Cc(64) {
continue;
}
match release.iter_mut().find(|r| r.channel == ev.channel) {
Some(r) if ev.tick >= r.tick => *r = *ev,
Some(_) => {}
None => release.push(*ev),
}
}
seq.ctrls.append(&mut kept);
for mut r in release.into_iter().filter(|r| r.value >= 64) {
r.tick = clip_end;
r.value = 0;
seq.ctrls.push(r);
}
}
}
+72 -1
View File
@@ -1,6 +1,6 @@
use super::import::add_source;
#[cfg(test)]
use super::midi::{MAX_MIDI_METERS, import_midi_bytes};
use super::midi::{MAX_MIDI_METERS, MAX_MIDI_TEMPOS, MAX_MIDI_TRACKS, import_midi_bytes};
use super::{AudioBuffer, Clip, ClipContent, Engine, EngineError, FileFormat, Range, Result, Samples, Session, TrackId, TrackKind};
/// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the
@@ -196,6 +196,77 @@ mod name_tests {
assert!(err.to_string().contains("meter changes"), "{err}");
}
/// A MIDI file with more tempo changes than MAX_MIDI_TEMPOS is refused without mutating the session.
#[test]
fn midi_with_more_tempo_changes_than_performances_have_is_refused() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
let smf = |n: usize| soundcraft_midi::Smf {
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence: sequence.clone() }],
tempos: (0..n as i64).map(|beat| (beat * 960, 100.0 + (beat % 40) as f64)).collect(),
..Default::default()
};
let mut e = Engine::default();
let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS + 1)).unwrap(), 0, true).unwrap_err();
assert!(err.to_string().contains("tempo changes"), "{err}");
assert_eq!((e.session().tempo.tempos().len(), e.session().tracks.len()), (1, 0), "nothing is imported");
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS)).unwrap(), 0, true).unwrap();
assert_eq!(e.session().tempo.tempos().len(), MAX_MIDI_TEMPOS);
}
/// A MIDI file with more tracks than MAX_MIDI_TRACKS is refused without mutating the session.
#[test]
fn midi_with_more_tracks_than_supported_is_refused() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
let smf = |n: usize| soundcraft_midi::Smf {
tracks: (0..n).map(|i| soundcraft_midi::SmfTrack { name: format!("Tr {i}"), sequence: sequence.clone() }).collect(),
..Default::default()
};
let mut e = Engine::default();
let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS + 1)).unwrap(), 0, false).unwrap_err();
assert!(err.to_string().contains("tracks"), "{err}");
assert_eq!(e.session().tracks.len(), 0, "no tracks imported");
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS)).unwrap(), 0, false).unwrap();
assert_eq!(e.session().tracks.len(), MAX_MIDI_TRACKS);
}
/// Consecutive equal tempo events in a MIDI file are collapsed to actual tempo changes.
#[test]
fn midi_collapses_equal_consecutive_tempos() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
let smf = soundcraft_midi::Smf {
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }],
tempos: vec![(0, 120.0), (960, 120.0), (1920, 140.0), (2880, 140.0), (3840, 100.0)],
..Default::default()
};
let mut e = Engine::default();
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap();
let tempos = e.session().tempo.tempos();
assert_eq!(tempos.len(), 3);
assert!((tempos[0].bpm - 120.0).abs() < 1e-3);
assert!((tempos[1].bpm - 140.0).abs() < 1e-3);
assert!((tempos[2].bpm - 100.0).abs() < 1e-3);
}
/// A MIDI file replaces an existing session tempo event even if the MIDI file has consecutive equal tempos.
#[test]
fn midi_import_replaces_differing_existing_session_tempo() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
let smf = soundcraft_midi::Smf {
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }],
tempos: vec![(0, 120.0), (960, 120.0)],
..Default::default()
};
let mut e = Engine::default();
e.session_mut().tempo.set_tempo(960, 80.0).unwrap();
assert_eq!(e.session().tempo.tempo_at_tick(960), 80.0);
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap();
assert_eq!(e.session().tempo.tempo_at_tick(960), 120.0);
}
#[test]
fn new_sources_take_the_first_free_file_name() {
let mut s = Session::default();
+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();
+246 -18
View File
@@ -11,6 +11,7 @@ pub mod catalog_more;
pub mod clip_group_file;
pub mod cmd;
pub mod demo;
mod dirty;
pub mod edit;
pub mod file_access;
pub mod inspect;
@@ -23,9 +24,17 @@ use soundcraft_time::Samples;
use std::cell::Cell;
use std::sync::Arc;
use dirty::same_document;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use soundcraft_model as model;
/// Upper bound on journaled commands, so a long session cannot grow memory without limit.
const MAX_JOURNAL_ENTRIES: usize = 10_000;
/// Upper bound on a single journaled entry's params (serialized), so junk fields from MCP or the
/// control channel cannot keep a huge blob alive for the life of the process.
const MAX_JOURNAL_ENTRY_BYTES: usize = 64 * 1024; // 64 KiB
/// Shuts the third-party plugin hosts down, running the module exit of every plugin binary they
/// loaded. Call it at the end of `main`, once every plugin instance (mixer, player) is gone: a
/// plugin that has been used can crash when its module exit only runs from an exit handler.
@@ -75,6 +84,8 @@ pub enum TransportRequest {
/// Stop the old session's transport without applying stop-time edits to the new document.
ResetSession,
Play,
/// Start playing at this position (or move there if already playing), ignoring the selection.
PlayFrom(Samples),
Stop,
TogglePlay,
Record,
@@ -108,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`.
@@ -117,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() }
}
@@ -173,8 +186,9 @@ pub struct Engine {
pub clipboard: Clipboard,
pub transport_requests: Vec<TransportRequest>,
pub transport: TransportStatus,
/// Command journal (id, params) for macro recording / agents.
pub journal: Vec<(String, Value)>,
/// Command journal (id, params) for macro recording / agents. Ring buffer: capped at
/// [`MAX_JOURNAL_ENTRIES`] entries of up to [`MAX_JOURNAL_ENTRY_BYTES`] params each.
pub journal: std::collections::VecDeque<(String, Value)>,
/// Path of the session file, if saved.
pub path: Option<String>,
/// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).
@@ -216,7 +230,7 @@ impl Engine {
clipboard: Clipboard::default(),
transport_requests: Vec::new(),
transport: TransportStatus::default(),
journal: Vec::new(),
journal: std::collections::VecDeque::new(),
path: None,
revision: 1,
dirty: false,
@@ -267,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;
@@ -322,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;
@@ -356,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) {
@@ -419,7 +473,7 @@ impl Engine {
self.push_undo(spec.label, before);
}
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
self.journal_append(id, params);
}
Ok(v)
}
@@ -486,6 +540,16 @@ impl Engine {
self.merge_key = None;
}
/// Append a journaled command, clamping its params to [`MAX_JOURNAL_ENTRY_BYTES`] and the
/// buffer to [`MAX_JOURNAL_ENTRIES`] entries (oldest are dropped).
fn journal_append(&mut self, id: &str, params: &Value) {
let clamped = (id.to_string(), journal_params(params));
self.journal.push_back(clamped);
if self.journal.len() > MAX_JOURNAL_ENTRIES {
self.journal.pop_front();
}
}
/// Convenience: execute with `{}` params.
pub fn run(&mut self, id: &str) -> Result<Value> {
self.execute(id, &json!({}))
@@ -511,19 +575,6 @@ impl Engine {
}
}
/// Equal apart from what changes without editing: the selection, the playhead and the zoom.
fn same_document(saved: &Session, doc: &Session) -> bool {
let mut doc = doc.clone();
let (from, to) = (&saved.edit, &mut doc.edit);
to.selection = from.selection;
to.timeline_selection = from.timeline_selection;
to.selected_tracks.clone_from(&from.selected_tracks);
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
*saved == doc
}
fn install_panic_hook() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
@@ -539,6 +590,92 @@ fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
/// The params to keep in the journal for a command: the value itself when it serializes within
/// [`MAX_JOURNAL_ENTRY_BYTES`], or a truncation marker when it is too large or unparseable.
///
/// Serializes into a [`std::io::Write`] sink that fails as soon as the cap is exceeded, so an
/// oversized `params` (a multi-MiB line from MCP or the control channel) is never fully
/// serialized, cloned, or allocated just to be discarded.
fn journal_params(params: &Value) -> Value {
// Write through a bounded sink: serialization stops (WriteZero) the moment the cap is passed,
// so an oversized value costs at most ~1× the cap, not its full serialized size.
struct Bounded(Vec<u8>);
impl std::io::Write for Bounded {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let room = MAX_JOURNAL_ENTRY_BYTES.saturating_sub(self.0.len());
if room == 0 {
return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap"));
}
let take = buf.len().min(room);
self.0.extend_from_slice(&buf[..take]);
if take < buf.len() {
return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap"));
}
Ok(take)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut sink = Bounded(Vec::new());
// Fails with WriteZero as soon as the cap is exceeded, so the allocation stays bounded even
// for a multi-MiB params.
let ok = serde_json::to_writer(&mut sink, params).is_ok();
if !ok {
return json!({"__truncated__": true});
}
params.clone()
}
#[cfg(test)]
mod journal_tests {
use super::*;
#[test]
fn journal_is_bounded_to_max_entries() {
let mut e = Engine::default();
// Calling the private append helper directly exercises the ring buffer without the cost
// of cloning the whole document on 10,000+ `track.new` calls.
for i in 0..MAX_JOURNAL_ENTRIES + 500 {
let n = format!("t{i}");
e.journal_append("track.new", &json!({"name": n}));
}
assert_eq!(e.journal.len(), MAX_JOURNAL_ENTRIES);
// The oldest entries were dropped, the newest kept: the first remaining is the 501st pushed,
// and the newest still there.
let first = e.journal.front().map(|(_, p)| p["name"].as_str().unwrap_or_default());
assert_eq!(first, Some("t500"));
let last = e.journal.back().map(|(_, p)| p["name"].as_str().unwrap_or_default());
let last_name = format!("t{}", MAX_JOURNAL_ENTRIES + 499);
assert_eq!(last, Some(last_name.as_str()));
assert_eq!(e.journal.back().map(|(id, _)| id.as_str()), Some("track.new"));
}
#[test]
fn oversized_params_are_truncated_in_the_journal() {
let big = "x".repeat(MAX_JOURNAL_ENTRY_BYTES * 4);
assert_eq!(journal_params(&json!({"name": big})), json!({"__truncated__": true}));
}
#[test]
fn small_params_are_kept_whole() {
let p = json!({"track": "Kick", "db": -6.0});
assert_eq!(journal_params(&p), p);
}
#[test]
fn cap_boundary_is_exact() {
// A value just under the cap is kept whole; one just over is truncated. "Just" means the
// bounded writer made at most ~two allocations of cap size, not a full multi-MiB buffer.
// `{"n":"…"}` adds 9 bytes around the string.
let small_str = MAX_JOURNAL_ENTRY_BYTES - 64;
let small = json!({"n": "x".repeat(small_str)});
assert_eq!(journal_params(&small), small);
let big = json!({"n": "x".repeat(MAX_JOURNAL_ENTRY_BYTES)});
assert_eq!(journal_params(&big), json!({"__truncated__": true}));
}
}
#[cfg(test)]
mod merge_tests {
use super::*;
@@ -589,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();
@@ -607,6 +797,44 @@ mod dirty_tests {
e.undo();
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();
let track = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
e.mark_clean();
e.execute("mix.volume", &json!({"track": track.0, "db": -6.0})).unwrap();
assert!(e.is_dirty());
// A repeated check uses the revision cache.
assert!(e.is_dirty());
e.mark_clean();
assert!(!e.is_dirty());
e.execute("edit.select", &json!({"tracks": [track.0], "start": 100, "end": 200})).unwrap();
e.execute("transport.locate", &json!({"at": 300})).unwrap();
e.execute("view.zoom_in", &json!({})).unwrap();
assert!(!e.is_dirty());
assert!(e.undo());
assert!(e.is_dirty(), "undoing away from the saved fader value is dirty");
assert!(e.redo());
assert!(!e.is_dirty(), "redoing to the saved fader value is clean despite navigation changes");
e.execute("mix.volume", &json!({"track": track.0, "db": -12.0})).unwrap();
assert!(e.is_dirty());
assert!(e.undo());
assert!(!e.is_dirty());
assert!(e.redo());
assert!(e.is_dirty());
}
}
#[cfg(test)]
+276 -16
View File
@@ -13,9 +13,26 @@ use std::fmt::Write as _;
const UNIT: i64 = TICKS_PER_QUARTER / 4;
/// Upper bound on engraved measures, so a hostile session cannot make a huge document.
const MAX_MEASURES: usize = 2000;
/// Upper bound on output size, so a hostile session cannot make a huge document.
const MAX_OUTPUT_BYTES: usize = 256 * 1024 * 1024; // 256 MiB
/// Upper bound on items per measure, so a hostile session cannot make a huge document.
const MAX_ITEMS_PER_MEASURE: usize = 128;
/// Upper bound on pitches per chord, so a hostile session cannot make a huge document.
const MAX_PITCHES_PER_CHORD: usize = 16;
/// Upper bound on notes collected per part, before `fill` builds onsets and spans, so a hostile
/// session cannot make `layout` accumulate an unbounded note vector.
const MAX_NOTES_PER_PART: usize = 100_000;
/// Note values a single notehead can show, in sixteenths (longest first).
const VALUES: [u32; 8] = [16, 12, 8, 6, 4, 3, 2, 1];
/// The output has grown past the byte budget: refuse to grow it further.
fn over_budget(buf: &str, budget: usize) -> Result<(), String> {
if buf.len() > budget {
return Err(format!("score too large: over {} bytes", budget));
}
Ok(())
}
/// Score Setup (File › Score Setup…). Stored in the session's edit flags/values.
#[derive(Debug, Clone, PartialEq)]
pub struct ScoreSetup {
@@ -93,6 +110,9 @@ pub fn layout(s: &Session, tracks: &[TrackId]) -> Vec<Part> {
let base = s.tempo.samples_to_ticks(c.start, sr);
let end = s.tempo.samples_to_ticks(c.start.saturating_add(c.length), sr);
for n in &sequence.notes {
if notes.len() >= MAX_NOTES_PER_PART {
break;
}
let st = base.saturating_add(n.start);
if st < base || st >= end || n.length <= 0 {
continue;
@@ -126,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;
}
@@ -151,13 +179,21 @@ fn split_units(mut units: i64) -> Vec<u32> {
}
fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec<Measure> {
// Onsets → chords, each held until its longest note ends or the next onset.
// Onsets → chords, each held until its longest *retained* note ends or the next onset. A pitch
// the cap rejects must not extend the onset end.
let mut onsets: std::collections::BTreeMap<i64, (i64, Vec<u8>)> = std::collections::BTreeMap::new();
for (a, b, p) in notes {
let e = onsets.entry(*a).or_insert((*b, Vec::new()));
e.0 = e.0.max(*b);
if !e.1.contains(p) {
e.1.push(*p);
let (ends, pitches) = onsets.entry(*a).or_insert((*b, Vec::new()));
let new_pitch = !pitches.contains(p);
if new_pitch && pitches.len() >= MAX_PITCHES_PER_CHORD {
// Chord full and this pitch is absent: it is dropped and must not extend the onset end.
continue;
}
// A retained pitch (already in the chord, or one the cap accepts) may extend the onset
// end, even when it reappears with a longer duration.
*ends = (*ends).max(*b);
if new_pitch {
pitches.push(*p);
}
}
let starts: Vec<i64> = onsets.keys().copied().collect();
@@ -196,10 +232,33 @@ fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec<Measure>
}
emit(from, to, ps, *a < *ms, *b > me, &mut items);
cursor = to;
// Cap items per measure to avoid pathological memory use.
if items.len() > MAX_ITEMS_PER_MEASURE {
break;
}
}
if cursor < me {
emit(cursor, me, &[], false, false, &mut items);
}
// Cap the item count without shortening the measure: whatever suffix the cap discards is
// replaced with representable rests, so the bar's duration (and the SVG layout denominator)
// stays right. Shrinking the item list further makes room for those rests.
if items.len() > MAX_ITEMS_PER_MEASURE {
let bar_len = me.saturating_sub(*ms);
let mut keep = MAX_ITEMS_PER_MEASURE;
loop {
let kept: i64 = items.iter().take(keep).map(|i| i64::from(i.units)).sum();
let rests = split_units(bar_len.saturating_sub(kept));
if keep.saturating_add(rests.len()) <= MAX_ITEMS_PER_MEASURE || keep == 0 {
items.truncate(keep);
items.extend(rests.into_iter().map(|units| Item { pitches: Vec::new(), units, tie_start: false, tie_stop: false }));
break;
}
keep -= 1;
}
// Safety net for a hostile meter so wide that even whole-bar rests exceed the cap.
items.truncate(MAX_ITEMS_PER_MEASURE);
}
measures.push(Measure { numerator: *num, denominator: *den, items });
}
measures
@@ -214,7 +273,13 @@ fn spell(pitch: u8) -> (char, i32, i32) {
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
s.chars()
.filter(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}'))
.collect::<String>()
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn note_type(units: u32) -> (&'static str, bool) {
@@ -231,11 +296,20 @@ fn note_type(units: u32) -> (&'static str, bool) {
}
/// MusicXML 4.0 (partwise) for `parts`.
pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result<String, String> {
musicxml_budgeted(parts, setup, MAX_OUTPUT_BYTES)
}
/// [`musicxml`] with an explicit byte budget (kept separate so tests can pass a tiny budget
/// instead of building a 256 MiB score).
fn musicxml_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<String, String> {
let mut x = String::new();
x.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n");
x.push_str("<!DOCTYPE score-partwise PUBLIC \"-//Recordare//DTD MusicXML 4.0 Partwise//EN\" \"http://www.musicxml.org/dtds/partwise.dtd\">\n");
x.push_str("<score-partwise version=\"4.0\">\n");
// The header (and an oversized title/composer, escaped) can itself exceed the budget even with
// no measures, so check before the per-measure loop too.
over_budget(&x, budget)?;
let _ = writeln!(x, " <work><work-title>{}</work-title></work>", xml_escape(&setup.title));
x.push_str(" <identification>\n");
if !setup.composer.is_empty() {
@@ -246,6 +320,7 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
let _ = writeln!(x, " <score-part id=\"P{}\"><part-name>{}</part-name></score-part>", i + 1, xml_escape(&p.name));
}
x.push_str(" </part-list>\n");
over_budget(&x, budget)?;
for (i, p) in parts.iter().enumerate() {
let _ = writeln!(x, " <part id=\"P{}\">", i + 1);
let mut meter = (0, 0);
@@ -312,11 +387,13 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
}
}
x.push_str(" </measure>\n");
over_budget(&x, budget)?;
}
x.push_str(" </part>\n");
}
x.push_str("</score-partwise>\n");
x
over_budget(&x, budget)?;
Ok(x)
}
/// Staff position of `pitch` in half-spaces above the bottom line (treble E4 / bass G2).
@@ -328,14 +405,22 @@ fn staff_step(pitch: u8, bass: bool) -> i32 {
}
/// An engraved score as a printable SVG page (A4 portrait width, as tall as needed).
pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result<String, String> {
svg_budgeted(parts, setup, MAX_OUTPUT_BYTES)
}
/// [`svg`] with an explicit byte budget (kept separate so tests can pass a tiny budget instead of
/// building a 256 MiB score).
fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<String, String> {
const W: f64 = 794.0;
const MARGIN: f64 = 48.0;
const SP: f64 = 7.0; // staff space
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;
@@ -350,7 +435,9 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
let _ = writeln!(o, "<text x=\"{}\" y=\"56\" font-size=\"24\" text-anchor=\"middle\">{}</text>", W / 2.0, xml_escape(&setup.title));
if !setup.composer.is_empty() {
let _ = writeln!(o, "<text x=\"{}\" y=\"84\" font-size=\"13\" text-anchor=\"end\">{}</text>", W - MARGIN, xml_escape(&setup.composer));
over_budget(&o, budget)?;
}
over_budget(&o, budget)?;
let bar_w = (staff_x1 - staff_x0) / per as f64;
for sys in 0..systems {
let y_sys = top + sys as f64 * sys_h;
@@ -462,7 +549,13 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
let _ = writeln!(o, "<path d=\"M{:.1} {y:.1} q 8 5 16 0\" fill=\"none\" stroke=\"black\" stroke-width=\"0.9\"/>", x + 3.0);
}
}
// Check each measure, so a run of rest-only or missing measures that only draws
// bar lines cannot grow the output past the budget untouched.
over_budget(&o, budget)?;
}
// The staff markup (lines, clef, track names) itself grows with the part count even
// without any items, so check once per staff too.
over_budget(&o, budget)?;
}
// System bracket.
let y_last = y_sys + (parts.len().max(1) - 1) as f64 * STAFF_GAP + 4.0 * SP;
@@ -472,9 +565,11 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
staff_x0 - 30.0,
staff_x0 - 30.0
);
over_budget(&o, budget)?;
}
o.push_str("</svg>\n");
o
over_budget(&o, budget)?;
Ok(o)
}
fn rest(o: &mut String, x: f64, y0: f64, sp: f64, units: u32) {
@@ -552,10 +647,175 @@ mod tests {
let parts = layout(e.session(), &[]);
assert!(!parts.is_empty(), "demo has MIDI tracks");
let setup = ScoreSetup::from_session(e.session());
let x = musicxml(&parts, &setup);
let x = musicxml(&parts, &setup).expect("demo musicxml fits");
assert!(x.contains("<score-partwise") && x.contains("<pitch>") && x.trim_end().ends_with("</score-partwise>"));
assert_eq!(x.matches("<measure ").count(), x.matches("</measure>").count());
let s = svg(&parts, &setup);
let s = svg(&parts, &setup).expect("demo svg fits");
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.
let bars: Vec<_> = (0..100).map(|i| (i * 16, 16, 4, 4)).collect();
let mut notes = Vec::new();
for i in 0..2000 {
notes.push((i * 2, i * 2 + 1, 60 + (i % 24) as u8));
}
let measures = fill(&bars, &notes);
let part = Part { name: "dense".into(), bass: false, measures };
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 4, show_track_names: false };
let s = svg(&[part], &setup);
assert!(s.is_ok(), "dense but bounded score fits");
let s = s.unwrap();
assert!(s.len() <= MAX_OUTPUT_BYTES);
}
#[test]
fn oversize_score_is_refused_not_aborted() {
// Many dense parts drive the output past the byte budget: the call must return an error,
// not grow a multi-GB string or abort. Use the budgeted entry points with a tiny budget so
// the test exercises the over-budget path without allocating 256 MiB of score, and covers
// both the per-measure checks and the header/staff-only growth.
let bars: Vec<_> = (0..100).map(|i| (i * 16, 16, 4, 4)).collect();
let mut notes = Vec::new();
for i in 0..2000 {
let onset = i * 2;
notes.push((onset, onset + 1, 60 + (i % 24) as u8));
}
let measures = fill(&bars, &notes);
let part = Part { name: "dense".into(), bass: false, measures };
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 1, show_track_names: true };
// Enough copies of the dense part to exceed a tiny budget.
let parts: Vec<Part> = (0..64).map(|i| Part { name: format!("part{i}"), ..part.clone() }).collect();
let tiny = 1024;
let err = svg_budgeted(&parts, &setup, tiny).unwrap_err();
assert!(err.contains("score too large"), "{err}");
// MusicXML needs a budget that clears the header/part-list but trips in the measures, so
// the per-measure check is what refuses the document (this test used to fail on the
// part-list check alone).
let boundless = usize::MAX;
// The header plus part-list exactly as `parse` writes it, but with no measures: that is
// the size a budget must clear before the per-measure check is reached.
let header_parts: Vec<Part> = parts.iter().map(|p| Part { name: p.name.clone(), bass: p.bass, measures: Vec::new() }).collect();
let header = musicxml_budgeted(&header_parts, &setup, boundless).map_or(0, |x| x.len());
let full = musicxml_budgeted(&parts, &setup, boundless).map_or(0, |x| x.len());
assert!(header > 0, "the bare header fits any budget");
assert!(full > header, "the measures add output ({full} vs {header})");
// A budget above the bare header but below the completed document must be refused by the
// per-measure check.
let between = header.saturating_add((full.saturating_sub(header)) / 2);
assert!(between > header && between < full, "budget sits between header and document");
// The header alone fits that budget, so the refusal below comes from the measures.
assert!(musicxml_budgeted(&header_parts, &setup, between).is_ok(), "header fits the mid budget");
let err = musicxml_budgeted(&parts, &setup, between).unwrap_err();
assert!(err.contains("score too large"), "{err}");
// And a header that does not fit is refused before any measure is built.
let err = musicxml_budgeted(&parts, &setup, tiny).unwrap_err();
assert!(err.contains("score too large"), "{err}");
}
#[test]
fn item_cap_keeps_the_measure_duration() {
// A 64/1 bar (256 sixteenths) with one note per sixteenth needs 256 items: over the cap.
// What the cap discards must come back as rests, so the bar still fills completely and the
// SVG layout denominator stays right.
let bars = vec![(0i64, 256i64, 64u32, 4u32)];
let notes: Vec<(i64, i64, u8)> = (0..256).map(|i| (i, i + 1, 60)).collect();
let measures = fill(&bars, &notes);
assert_eq!(measures.len(), 1);
let m = &measures[0];
assert!(m.items.len() <= MAX_ITEMS_PER_MEASURE, "capped at {MAX_ITEMS_PER_MEASURE}");
let total: u32 = m.items.iter().map(|i| i.units).sum();
assert_eq!(total, 256, "the bar still lasts 256 sixteenths");
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:
// the escaped copy alone exceeds the budget. (A 54 MiB title blows past 64 KiB of escaped
// text without touching the per-measure loop.)
let setup = ScoreSetup { title: "&".repeat(64 * 1024), composer: "c".repeat(64 * 1024), bars_per_system: 4, show_track_names: false };
let err = musicxml_budgeted(&[], &setup, 1024).unwrap_err();
assert!(err.contains("score too large"), "{err}");
let err = svg_budgeted(&[], &setup, 1024).unwrap_err();
assert!(err.contains("score too large"), "{err}");
}
#[test]
fn exports_drop_characters_xml_forbids() {
let e = crate::demo::demo_engine();
let mut parts = layout(e.session(), &[]);
parts[0].name = "Lead\u{1}1\u{FFFE}".to_string();
let mut setup = ScoreSetup::from_session(e.session());
setup.title = "T\u{2}itle".to_string();
setup.composer = "C\u{3}omp".to_string();
for out in [musicxml(&parts, &setup).expect("musicxml fits"), svg(&parts, &setup).expect("svg fits")] {
let bad: Vec<char> =
out.chars().filter(|c| (*c < ' ' && !matches!(c, '\n' | '\t' | '\r')) || matches!(c, '\u{FFFE}' | '\u{FFFF}')).collect();
assert!(bad.is_empty(), "forbidden characters: {bad:?}");
}
}
}
+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();
+166
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)]);
@@ -299,6 +422,22 @@ fn shuffle_clear_moves_automation_with_the_clips() {
assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]);
}
/// Clearing selected clips by id closes their gaps in Shuffle, as clearing their ranges does, and moves the
/// automation with the clips.
#[test]
fn shuffle_clear_by_clip_id_closes_each_gap() {
for (mode, want, want_points) in [
("shuffle", [(0, 10_000), (20_000, 10_000)], [0, 9_000, 20_000, 29_000]),
("slip", [(0, 10_000), (40_000, 10_000)], [0, 9_000, 40_000, 49_000]),
] {
let (mut e, t, ids) = long(mode, &[(0, 10_000, 0), (20_000, 10_000, 0), (30_000, 10_000, 0), (40_000, 10_000, 0)]);
set_points(&mut e, t, &[0, 9_000, 40_000, 49_000]);
run(&mut e, "edit.clear", json!({"clips": [ids[1].0, ids[2].0], "tracks": [t.0], "start": 0}));
assert_eq!(clips(&e, t), want, "{mode}");
assert_eq!(points(&e, t), want_points, "{mode}");
}
}
#[test]
fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() {
let (mut e, t, [a, _]) = engine("relative_grid");
@@ -413,3 +552,30 @@ fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() {
run(&mut e, "edit.mode", json!({"mode": "grid"}));
assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err());
}
fn selection_marker(e: &mut Engine, start: i64, end: i64) -> usize {
run(e, "markers.add", json!({"kind": "selection", "start": start, "end": end}));
e.session().markers.len() - 1
}
#[test]
fn cut_and_insert_time_move_the_end_of_a_spanning_selection_memory_location() {
let (mut e, _, _) = engine("slip");
let a = selection_marker(&mut e, 1000, 5000);
let b = selection_marker(&mut e, 1000, 2500);
run(&mut e, "event.cut_time", json!({"start": 2000, "end": 3000}));
let m = &e.session().markers;
assert_eq!((m[a].start, m[a].end), (1000, 4000));
assert_eq!((m[b].start, m[b].end), (1000, 2000));
run(&mut e, "event.insert_time", json!({"start": 2000, "length": 1000}));
let m = &e.session().markers;
assert_eq!((m[a].start, m[a].end), (1000, 5000));
assert_eq!((m[b].start, m[b].end), (1000, 2000));
}
#[test]
fn moving_a_group_left_past_zero_keeps_its_spacing() {
let (mut e, t, ids) = long("slip", &[(500, 100, 0), (2000, 100, 0)]);
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": -1000}));
assert_eq!(clips(&e, t), [(0, 100), (1500, 100)]);
}
+3
View File
@@ -11,10 +11,13 @@ mod hostile_params;
mod hostile_sessions;
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;
mod split_into_mono;
mod time_key_signatures;
mod track_name_collisions;
mod trim_to_selection;
+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)]);
}
@@ -0,0 +1,11 @@
use serde_json::json;
use soundcraft_engine::Engine;
#[test]
fn min_secs_string_is_not_misread_as_timecode_when_the_counter_is_timecode() {
let mut e = Engine::default();
e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap();
e.execute("edit.select", &json!({"start": "0:12.500", "end": "0:20.000"})).unwrap();
let sel = e.session().edit.selection;
assert_eq!((sel.start, sel.end), (600_000, 960_000));
}
+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);
}
@@ -0,0 +1,41 @@
use serde_json::json;
use soundcraft_engine::Engine;
use soundcraft_model::{ChannelFormat, Session, TrackKind};
fn engine_with_keys() -> Engine {
let mut session = Session::default();
session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut engine = Engine::new(session);
for (at, key) in [(10, "C major"), (48000, "D major"), (100000, "E major")] {
engine.execute("event.add_key_change", &json!({"key": key, "at": at})).unwrap();
}
engine
}
fn keys(engine: &Engine) -> Vec<(u64, String)> {
engine.session().key_signatures.iter().map(|(at, k)| (*at as u64, k.clone())).collect()
}
fn k(at: u64, key: &str) -> (u64, String) {
(at, key.to_string())
}
#[test]
fn insert_time_moves_key_signatures() {
let mut engine = engine_with_keys();
let original = keys(&engine);
engine.execute("event.insert_time", &json!({"start": 48000, "length": 1000})).unwrap();
assert_eq!(keys(&engine), vec![k(10, "C major"), k(49000, "D major"), k(101000, "E major")]);
engine.undo();
assert_eq!(keys(&engine), original);
}
#[test]
fn cut_time_removes_and_moves_key_signatures() {
let mut engine = engine_with_keys();
let original = keys(&engine);
engine.execute("event.cut_time", &json!({"start": 20, "end": 60000})).unwrap();
assert_eq!(keys(&engine), vec![k(10, "C major"), k(40020, "E major")]);
engine.undo();
assert_eq!(keys(&engine), original);
}
+3 -2
View File
@@ -178,9 +178,10 @@ pub fn change_velocity_with_seed(notes: &mut [Note], op: VelocityOp, seed: u64)
VelocityOp::Ramp { from, to } => {
let first = notes.iter().map(|n| n.start).min().unwrap_or(0);
let last = notes.iter().map(|n| n.start).max().unwrap_or(0);
let span = last.saturating_sub(first) as f64;
let span = (i128::from(last) - i128::from(first)) as f64;
for n in notes.iter_mut() {
let t = if span > 0.0 { n.start.saturating_sub(first) as f64 / span } else { 0.0 };
let offset = (i128::from(n.start) - i128::from(first)) as f64;
let t = if span > 0.0 { offset / span } else { 0.0 };
let v = f64::from(from) + (f64::from(to) - f64::from(from)) * t;
n.velocity = clamp_vel(round_i64(v));
}

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