Compare commits

...
410 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 01b39850ad Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:29:18 -04:00
Brandon ThomasandClaude Opus 5.5 b48df2a8b9 Release workflow: grant contents: write on the release job
#988 dropped the workflow-level permission for SonarCloud S8233 but never added the job-level one, so the 0.13.0 release run failed at startup: the shared workflow's draft-release job asks for contents: write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:29:08 -04:00
Brandon ThomasandClaude Opus 5.5 106678471f Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:26:25 -04:00
aiongg 541096d2cb Merge pull request #1058 from aiongg/dotcoocoo-batch
Merge 13 open PRs (tested batch)
2026-10-25 00:54:45 -04:00
aiongg 353ea145aa Integration: AudioSuite speed ramp: Fit To plays the first two thirds for Slow Down and the last for Speed Up 2026-10-25 04:36:02 +00:00
aiongg 35905fb90b Integration: Auto Rename: a loaded scheme leaves room for its number 2026-10-25 03:53:11 +00:00
aiongg d7b1562cc1 Integration: Strip Silence naming: rustfmt 2026-10-25 02:54:13 +00:00
aiongg 9048e04d60 Integration: AudioSuite speed ramp: the clip suffix is speeramp
AudioSuite clip suffixes take four letters from each word of the process
id, so speed_ramp gives -speeramp.
2026-10-25 02:46:30 +00:00
aiongg e954639900 Integration: docs: built-in plugin counts are 27 processors + 3 instruments
The registry holds Auto Pan and Subharmonic as well; the 3bc2d92 snapshot
keeps its own count.
2026-10-25 02:33:30 +00:00
aiongg bbebfa1a6e Merge PR #1023: Docs: the built-in plugin counts match the registry, and the sampler is no longer listed as missing 2026-10-25 02:33:05 +00:00
aiongg 9821168bc4 Merge PR #1054: Clip List: Auto Rename sets the names of the clips separated from a clip
# Conflicts:
#	ROADMAP.md
2026-10-25 02:33:05 +00:00
aiongg e46339606a Merge PR #1055: Strip Silence: rectangles show what Strip keeps while the window is open
# Conflicts:
#	ROADMAP.md
2026-10-25 02:32:58 +00:00
aiongg 835de9f038 Integration: Strip Silence naming: refuse a name and suffix too long for a clip name
A name and suffix longer than MAX_NAME_CHARS - 29 leave no room for the
number, and a session truncates such names on open.
2026-10-25 02:32:54 +00:00
aiongg e910e72940 Merge PR #1044: Strip Silence: Rename sets the names of the clips it leaves
# Conflicts:
#	ROADMAP.md
2026-10-25 02:32:31 +00:00
aiongg 54467f544b Merge PR #1040: Strip Silence: Extract keeps the silence, and Separate cuts the clips without removing any
# Conflicts:
#	ROADMAP.md
2026-10-25 02:32:22 +00:00
aiongg 4311742624 Merge PR #1030: Clip > Loop... opens Clip Looping: Number of Loops, Loop Length, or until the next clip or the session end
# Conflicts:
#	ROADMAP.md
2026-10-25 02:32:11 +00:00
aiongg a8ca533b9b Merge PR #1053: Fades: Create refuses a shape that is not a string over a selection 2026-10-25 02:31:53 +00:00
aiongg 3a139d4bed Integration: AudioSuite speed ramp: name SoundCraft's ids speed_ramp
The process id, types, function, test module and clip suffix use
speed_ramp; the menu label stays as the catalog has it.
2026-10-25 02:31:48 +00:00
aiongg 092122b49c Merge PR #1016: AudioSuite: Vari-Fi slows a clip down to a stop or speeds it up from one
# Conflicts:
#	ROADMAP.md
#	docs/plugin-parity.md
2026-10-25 02:31:05 +00:00
aiongg be75be896a Merge PR #1013: Add a built-in Auto Pan plugin
# Conflicts:
#	README.md
#	ROADMAP.md
#	docs/plugin-parity.md
2026-10-25 02:30:52 +00:00
aiongg f77c360f94 Merge PR #1019: Add a built-in Subharmonic plugin
# Conflicts:
#	docs/plugin-parity.md
2026-10-25 02:30:22 +00:00
aiongg a6cec46275 Merge PR #1010: Add a built-in Ring Modulator plugin 2026-10-25 02:30:09 +00:00
aiongg 10e26f6fe2 Merge PR #1018: CLI tests: the several-roots test uses plain paths, so it passes on Windows 2026-10-25 02:30:09 +00:00
aiongg c0dc2c3ec6 Merge PR #1017: Autosave: allow the Unix-only mut, so clippy passes on Windows 2026-10-25 02:30:09 +00:00
dotCooCoo 06f30628bb Clip List: a paste reuses a held Auto Rename scheme only when its text matches too
A paste reused the scheme of the session it went into whenever the
copied scheme's id matched, so an unrelated scheme that had the same id
could rename the pasted clip and count its numbers on. A scheme is now
reused only when its name, zeros and suffix match as well; any other is
added under a new key.
2026-10-24 18:37:54 -07:00
dotCooCoo 746a825ed5 Strip Silence: rectangles show what Strip keeps while the window is open
While the Strip Silence window is open, the Edit window draws a
rectangle around each range that Strip would keep on the selected
tracks: the audio above the threshold, padded by Clip Start Pad and
Clip End Pad. The rectangles follow the sliders and stay while Rename
Selected Clips is open. They are removed when the window closes. The
audio and the clips do not change.

The scan runs again when a value or the session changes. Outside the
web build it runs on a worker thread, one scan at a time, and the
rectangles last found stay until it ends. A scan for older values, or
one still running after the window closes, stops before its next
window of audio.

The new query command edit.strip_silence_preview returns the same
ranges for each track. It reads the threshold, minimum strip duration
and pads with the defaults and limits of edit.strip_silence, and both
use the same scan.
2026-10-24 18:17:53 -07:00
dotCooCoo fe4cd8095d Clip List: Auto Rename sets the names of the clips separated from a clip
Clip List › Auto Rename… opens Rename Selected Clips for the selected
clips. Name, Starting Number, Number of Places and Suffix set the names
that editing gives the clips it separates from them: the name, as many
zeros as Number of Places, a number counting up from Starting Number,
and the suffix. The piece after each cut is the new clip and takes the
next name. The piece before it keeps its name. The clips separated from
one clip, and from every clip that got the scheme together, count up
one number sequence. The guide does not say how Strip Silence's own
Rename fields and Auto Rename combine. Here, when Strip Silence's own
Rename fields are set, they name the audio pieces it cuts inside its
range, and those pieces take no Auto Rename name and do not count the
Auto Rename number up. In Strip and Extract, only the pieces that Strip
Silence keeps take Auto Rename names, in time order, so their numbers
have no gaps.

clip.auto_rename takes clips, name, number, zeros and suffix, with the
same limits and errors as edit.strip_silence. Without a name the clips
get no scheme. Cut and Copy keep a copy of the copied clips' schemes,
and a paste uses the session's scheme with the same id or adds the
copy under a new key, so a pasted copy keeps its names after an undo
or in another session. Saved sessions hold the schemes, and opening a
session repairs them and drops the ones no clip uses. Import Session
Data brings them in under new keys. Clips placed from a clip group file
get no scheme.
2026-10-24 17:12:22 -07:00
dotCooCoo a41f254aa0 Fades: Create refuses a shape that is not a string over a selection
edit.fades_create over tracks and a range read a null, boolean or number
shape as absent and made Equal Power fades. It now returns an error that
lists the accepted shapes, as the clip form already did, and leaves the
session and the undo history unchanged.
2026-10-24 15:19:28 -07:00
dotCooCoo 698507cf30 Strip Silence: Rename sets the names of the clips it leaves
The Strip Silence window has a Rename… button. It opens Rename Selected
Clips, where Name, Number, Zeros and Suffix set the names that Strip,
Extract and Separate give the clips they leave in the selection: the
name, that many zeros, a number counting up from Number, and the
suffix. SFX, 23, 1 and .Reel1 give SFX023.Reel1, SFX024.Reel1 and so
on. The app keeps the scheme for later uses of Strip Silence until
the app quits, or until Clear and then OK empty it. OK, Cancel and
Escape go back to Strip Silence with its slider values.

edit.strip_silence takes name, number, zeros and suffix. Numbering runs
on from one track to the next, and the result reports how many clips
were named. Without a name the clips keep their names. A name or suffix
that is not text, a number out of range, or a number, zeros or suffix
without a name is an error and leaves the tracks unchanged.
2026-10-24 14:18:26 -07:00
dotCooCoo 1e9a0e9c88 Strip Silence: a mode that is not a string is refused
edit.strip_silence read a null, boolean or number mode as no mode and ran Strip. It now returns an error and leaves the track unchanged, as it does for an unknown mode name.
2026-10-24 13:46:21 -07:00
aiongg ee050db59c ui: move panel docking onto craft-ui's shared Workspace and DockCommand (#1035)
* ui: move panel docking onto craft-ui's shared Workspace and DockCommand

Pin every craft-libs crate at 16fb3d4 (craft-libs main after #285).

The Edit and Mix workspaces are craft_ui::docking::Workspace<Panel>.
ui.dock and ui.dock.reset parse into DockCommand; the app keeps its
panels, permissions, defaults, the document-view and Mix rules, the
beside-the-canvas fallback for panels without a recorded place, and
Arrange. The canvas accepts splits so panels still open beside it.

Saved v1 workspaces (no version, floating windows as
{panels, active, rect}) load unchanged. Tests cover loading one and
dragging a group by its drag bar.

* ui: test that activating a hidden panel shows it

* ui: name the field in ui.dock operation errors; document the shared panel menu and zones
2026-10-24 16:18:19 -04:00
dotCooCoo 0ca492e0ab Strip Silence: Extract keeps the silence, and Separate cuts the clips without removing any
The Strip Silence window had only a Strip button. It now also has
Extract and Separate. Extract removes the parts of the selection that
hold audio and keeps the silent parts. Separate separates the clips at
the same points Strip does and does not remove any clip. Enter on a
focused Extract or Separate runs that button, and Enter presses Strip
only when neither was clicked.

edit.strip_silence takes a mode param: strip (the default), extract or
separate. An unknown mode is an error. The Minimum Strip Duration
slider now goes up to 4,000 ms.
2026-10-24 12:57:38 -07:00
dotCooCoo 3e56738290 Vari-Fi: hold the first and last samples at the ends of the input
Slow Down reads up to the end of the input as it stops, and Speed Up starts
reading from its first sample. The interpolation dropped the taps past
either end, so with Fades Off a Slow Down faded to silence at the stop and
a Speed Up started about 1.5% low. Those taps now read the first or last
sample; a channel shorter than the others stays silent past its end.
2026-10-24 10:26:42 -07:00
dotCooCoo d5be1e6de3 Clip > Loop... opens Clip Looping: Number of Loops, Loop Length, or until the next clip or the session end
Clip > Loop... and Cmd+Alt+L looped the selected clip four times at once.
They now open the Clip Looping dialog. Number of Loops counts the clip and
its loops together, 4 by default as before. Loop Length is typed in the
main counter's time format, and the last loop is cut short when the
length is not a whole number of loops. Loop Until End of Session or Next
Clip loops up to the next clip on the track, or else to the end of the
last clip in the session.

clip.loop takes the new fill option, and its length option now cuts the
last loop short instead of rounding down to whole loops. A fill or a
length that needs more than 1,000 loops is refused with an error instead
of stopping at 1,000 loops. The cut-short loop is trimmed with
Clip::trim_end_to, which shortens its fades to fit and removes a sync
point that falls past the cut.

Unloop takes a shorter last clip as part of the loop when it shares the
loop, no clip of the loop starts where it ends, and either a whole copy
comes before it or it holds the same material as the origin just before
it. It removes a short last loop too, and leaves both halves of a split
clip in place.
2026-10-24 09:14:06 -07:00
dotCooCoo 7b19c3068a ROADMAP: set Last reviewed to the date of this update 2026-10-24 07:54:18 -07:00
dotCooCoo 9b60e84fc3 Merge main into feat/subharmonic 2026-10-24 07:51:50 -07:00
dotCooCoo 02b153797d Merge main into feat/vari-fi-speed-ramps 2026-10-24 07:44:44 -07:00
dotCooCoo 4a0e4caa3f Merge main into feat/auto-pan 2026-10-24 07:35:50 -07:00
dotCooCoo dc9e84978f Docs: the built-in plugin counts match the registry, and the sampler is no longer listed as missing
The plugin registry in crates/dsp/src/plugins/mod.rs holds 25 processors
and 3 instruments, as docs/plugin-parity.md already says, but gaps.md
and target-app-parity.md still said 26 processors and 2 instruments,
and roadmap.md said 26 built-in processors. At 3bc2d92 the registry
held 24 processors and 2 instruments, so the code snapshot in
target-app-parity.md now gives those counts.

The Sampler merged in #108, but G-20, the beta plan's plugin essentials
and the MIDI and instruments row still listed it as missing or as an
open pull request.
2026-10-24 07:34:49 -07:00
aiongg aa74bffac1 Merge pull request #1011 from aiongg/next
Integration batch 1: 36 contributor PRs (files, editing, sessions, CLI, mixer, MIDI, plug-ins, interface)
2026-10-24 08:38:37 -04:00
dotCooCoo 40ced8132b Add a built-in Subharmonic plugin
The built-in plugins had no subharmonic synthesizer. Subharmonic
(`subharmonic`, Harmonic category, also offline as AudioSuite) adds the
octave below a lower and an upper band of the input. The Range picks the
two band frequencies (120-180, 80-120, 60-90 or 40-60 Hz). Each band
band-passes the input at its frequency and multiplies it by a flip-flop
that changes sign on every rising zero crossing, which moves the band an
octave down at a level that follows the band. A direct band adds the
input's own bass.

Each band has a gain from -120 to +24 dB and a solo; a solo also mutes
the dry signal. Drive raises the summed bands into one of six saturation
characters, and HP and LP filters (5 to 1000 Hz, Q 0.1 to 5) shape the
result. Below 50 % Mix the dry signal stays at full level, above 50 % the
wet signal does, and Wet Solo mutes the dry signal. Input and Output run
from -18 to +18 dB.

The Total row in docs/plugin-parity.md now counts 25 processors and 3
instruments, the entries in the built-in registry, and its Instruments
row lists `sampler` and no longer lists the Sampler as missing.
2026-10-24 04:57:37 -07:00
dotCooCoo b322ff2f13 CLI tests: the several-roots test uses plain paths, so it passes on Windows
cli::mcp_automation_roots_take_several_folders built its folders from
std::fs::canonicalize, which returns \\?\C:\... on Windows. The server
prints its roots without that prefix (file_access::plain), so the stderr
check never matched. The test also sent \\?\ paths with `..` in them,
which Windows does not normalize, so that call failed with "cannot
resolve" instead of "outside the read roots".

A test helper now canonicalizes and strips \\?\ (and \\?\UNC\) on
Windows, as file_access::plain does, and the test builds every path from
it. On macOS and Linux the paths are unchanged.
2026-10-24 03:19:55 -07:00
dotCooCoo c5ddbbafd1 Autosave: allow the Unix-only mut, so clippy passes on Windows
Autosave::create builds each session folder with a DirBuilder that only
the #[cfg(unix)] mode call mutates. On Windows, clippy -D warnings
reports the binding as unused_mut, and cargo xtask ci stops at its
clippy step. The binding now carries
#[cfg_attr(not(unix), allow(unused_mut))], as the recovery test's paths
in the same file does. Nothing changes at run time.
2026-10-24 02:55:17 -07:00
dotCooCoo f0f9addd16 AudioSuite: Vari-Fi slows a clip down to a stop or speeds it up from one
AudioSuite > Pitch Shift > Vari-Fi resampled the clip at a constant
speed. It now ramps the speed between normal and a complete stop, so
the pitch falls or rises with it. Slow Down and Speed Up set the
direction. Fit To keeps the clip's length and plays two thirds of it;
Extend plays all of it and makes the clip 150 % long. Fades On fades
the audio out with the slowdown or in with the speedup.

The ramp is offline::vari_fi, run by audiosuite.process as vari_fi
with change, selection and fades params. The constant-speed varispeed
process stays available to commands.
2026-10-24 02:24:30 -07:00
aiongg e884d6eb37 Integration: VST3 host: keep room for note releases after a reset 2026-10-24 09:16:19 +00:00
aiongg 4ab3bde39b Integration: MIDI real-time properties: reload the form when stored values change 2026-10-24 09:15:31 +00:00
aiongg 824cf5cecb Integration: import: keep a failed import's error on the status line 2026-10-24 09:14:21 +00:00
aiongg f6a27c2ccf Integration: automation: hold clip gain envelope ends to the clip edges 2026-10-24 09:13:19 +00:00
aiongg 08679d5a65 Integration: cli: run the inline --cmd=ID form 2026-10-24 09:11:44 +00:00
Dylan M. TaylorandClaude Sonnet 5.5 93800e0764 refactor(video): use method paths instead of closures for first_chunk (S1612) (#1012)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:30:47 -04:00
dotCooCoo 33c979cd34 List the sampler in the plugin parity Instruments row
The Instruments row now names `sampler` beside `subtractive_synth` and
`drum_synth`, matching the 3 instruments in the Total row, and its Missing
column no longer lists the Sampler, which #108 added.
2026-10-24 01:06:32 -07:00
dotCooCoo a05b1e7dc1 Add a built-in Auto Pan plugin
Auto Pan (`auto_pan`) pans the mono sum of a stereo track between the
left and right channels with constant-power gains. The position is
Manual scaled by (1 - Width) plus the modulator scaled by Width, and
Path sets the direction. The modulator is an LFO with seven waveforms,
or an envelope follower that moves the signal from the start of the
path at the threshold to the end at full scale.

The LFO rate is set in BPM, or follows the session tempo with Link to
Tempo, and Duration (a 16th note to 4 bars) sets the length of one
cycle at that rate. The LFO runs free, runs once, restarts at every
Duration on the transport (Beat Clock; bar Durations count from the
bar line), or runs one cycle each time the level rises through the
threshold or the Manual Trigger switch changes. After an envelope
trigger, the next one waits until the level has stayed below the
threshold for the Release time. Output sets the level from -24 dB to
+12 dB. On a mono track the insert has one channel, and only Output
applies.

The plugin parity doc, ROADMAP and the README plugin list name it, and
the parity doc's built-in count now reads 25 processors + 3
instruments.
2026-10-24 01:02:54 -07:00
aiongg c29510af86 Integration: regenerate docs/parity-checklist.md 2026-10-24 06:56:33 +00:00
aiongg 1cc7b40e6a Integration: #631 find the New Tracks name field by its text-input role 2026-10-24 06:54:11 +00:00
aiongg 42f22369fb Integration: build the batch's new integration tests into each crate's tests/it binary 2026-10-24 06:54:11 +00:00
aiongg b47f544c7a Merge track r1-t02
# Conflicts:
#	contributors/people.toml
#	docs/plugin-parity.md
2026-10-24 06:54:04 +00:00
aiongg 7bf20dbbac Merge track r1-t01 2026-10-24 06:54:04 +00:00
aiongg de206f46b6 Merge PR #798: Fix saved audio trust test on Windows by joining path parts separately (#789) 2026-10-24 06:53:57 +00:00
aiongg 4f959bee40 Merge PR #587: Round negative timecode and feet+frames positions symmetrically 2026-10-24 06:53:57 +00:00
aiongg 0ca5dd74ab Merge PR #967: Name F12 in the Record button's tooltip 2026-10-24 06:53:57 +00:00
aiongg 848978a4af Merge PR #997: Fix the macOS build: import registry in au-host's api module 2026-10-24 06:53:57 +00:00
aiongg f3b897f532 Merge PR #662: fix(ui): capture live plugin state before saving track presets
# Conflicts:
#	docs/gaps.md
2026-10-24 06:53:57 +00:00
aiongg c13e55c7c3 Merge PR #484: VST3 host: IMessage and IAttributeList for plugins (Helix loads its database)
# Conflicts:
#	ROADMAP.md
2026-10-24 06:53:57 +00:00
aiongg 419e7726bb Merge PR #605: Release held VST3 notes on reset instead of discarding them 2026-10-24 06:53:57 +00:00
aiongg bc530b172a Merge PR #763: Mix tests: never load real plug-ins (cargo test hangs on machines with plug-ins installed) 2026-10-24 06:53:56 +00:00
aiongg fb9d4ac78b Merge PR #1008: Tracks: clip track and group names to their rows in the side lists (fixes #990) 2026-10-24 06:53:56 +00:00
aiongg 76bd7bc3af Merge PR #561: Keep deep Aux and Folder routing chains in dependency order
# Conflicts:
#	contributors/people.toml
2026-10-24 06:53:56 +00:00
aiongg c4fee770ca Merge PR #784: Fix empty SMF export re-importing as one phantom empty track 2026-10-24 06:53:56 +00:00
aiongg c0f72e5fe3 Merge PR #593: ui: MIDI Real-Time Properties submits only the property that changed (#444) 2026-10-24 06:53:56 +00:00
aiongg 7ec4216ba2 Merge PR #565: A Pencil click on a MIDI clip selects it, so the MIDI Editor follows 2026-10-24 06:53:56 +00:00
aiongg d33563ebe2 Merge PR #521: Keep MIDI note selection valid across clip, session and note-order changes (#443)
# Conflicts:
#	crates/engine/src/cmd/midi.rs
2026-10-24 06:53:56 +00:00
aiongg 95243c7698 Merge PR #1010: Add a built-in Ring Modulator plugin
# Conflicts:
#	ROADMAP.md
2026-10-24 06:40:52 +00:00
aiongg 4240873586 Merge PR #551: Bounce: a Dither option in the dialog and for stems (#522) 2026-10-24 06:40:52 +00:00
aiongg 9eb2077dd1 Merge PR #643: Resample through a polyphase bank: 5x faster rate-converting imports (#597) 2026-10-24 06:40:52 +00:00
aiongg 3fe53ee834 Integration: #595 pass BUNDLE_LIMITS in main's sources_sharing_one_file test 2026-10-24 06:19:20 +00:00
aiongg 9e7583d99b Merge PR #595: Save a session bundle only if it opens again (#433)
crates/engine/src/io.rs: kept both bundle tests.
2026-10-24 06:19:17 +00:00
aiongg 9f03eeb354 Merge PR #586: session: reject duplicate ids and an exhausted id space on load 2026-10-24 06:19:10 +00:00
aiongg ec7cfbb8ac Merge PR #519: Load older sessions whose track height is the bare Fit string (#447) 2026-10-24 06:19:09 +00:00
aiongg d2d6518a5f Merge PR #557: mix: clamp negative fractional source position in stretched-clip rendering 2026-10-24 06:19:09 +00:00
aiongg 045938728f Integration: #674 advance the source offset by delta/stretch on start trims and compare heal offsets in source time 2026-10-24 06:19:09 +00:00
aiongg 2e17315d5d Merge PR #674: Fix source offset when trimming the start of a stretched audio clip
crates/engine/src/edit.rs: kept main's gap-aware heal comparison and doc comment, the PR's source_len helper, and both sets of tests.
crates/model/src/clip.rs: kept main's clamped trim_start_to body (the stretch-aware offset follows in the Integration commit) and both tests.
2026-10-24 06:19:06 +00:00
aiongg 678cb0f57d Integration: #517 join_gain_env places the right envelope at the seam when heal bridges a gap 2026-10-24 06:18:49 +00:00
aiongg a1eafbd1c5 Merge PR #517: Heal keeps each clip's gain envelope and skips clips whose mute or stretch differ (#450)
crates/engine/src/edit.rs: heal combines main's gap-aware join (contiguous, gap, saturating adds) with the PR's same_playback check and join_gain_env call; both heal tests kept.
2026-10-24 06:18:46 +00:00
aiongg c8f44fbbef Merge PR #518: Keep unrelated overlapping clips untouched when deleting fades (#449)
crates/engine/src/edit.rs: kept both tests; delete_fades body merged without conflict.
2026-10-24 06:17:53 +00:00
aiongg fd07262983 Merge PR #760: Bounce the whole session when CLI --bounce has no range 2026-10-24 06:17:41 +00:00
aiongg 9719f955a4 Integration: #552 allow --dither and --no-dither in convert's options 2026-10-24 06:17:33 +00:00
aiongg 98aa5dd1b5 Merge PR #555: convert: dither only when the output loses resolution (#548)
apps/soundcraft-cli/src/main.rs: kept main's OsStr::to_str spelling and the PR's dither lines.
2026-10-24 06:17:33 +00:00
aiongg 9a5ab203d2 Merge PR #552: cli: reject unknown options and missing option values 2026-10-24 06:17:18 +00:00
aiongg 0cb3c692ac Merge PR #716: Keep the existing volume lane in automation.clip_gain_to_volume 2026-10-24 06:17:18 +00:00
aiongg 5bd755f604 Merge PR #617: Apply strip mute automation within each processing block
contributors/people.toml: kept both new entries.
2026-10-24 06:17:15 +00:00
aiongg b0e35aa988 Merge PR #792: Preserve finite headroom in Float32 WAV and AIFF-C encoding 2026-10-24 06:17:04 +00:00
aiongg bb990d9bfa Merge PR #663: Fix Split into Mono repeating the left channel
ROADMAP.md, docs/gaps.md: kept the newer header line, union of log, table and G-04 entries
crates/engine/src/cmd/track.rs: kept main's explicit imports, added ClipContent
2026-10-24 06:16:56 +00:00
aiongg 2adec7f128 Integration: #509 accept the Ambisonic WAVEX subtype GUIDs as PCM/float 2026-10-24 06:16:40 +00:00
aiongg 038c4c0ca7 Merge PR #636: Import audio off the UI thread (#597)
crates/engine/src/io.rs: kept the PR's decode_import_bytes split; main's OsStr::to_str spelling.
crates/engine/src/cmd/file.rs: kept the PR's take_import path; main's OsStr::to_str spelling.
2026-10-24 06:16:25 +00:00
aiongg fc87f5a5c6 Merge PR #346: Prevent track-name collisions after truncation
# Conflicts:
#	contributors/people.toml
2026-10-24 06:16:17 +00:00
aiongg a851761270 Merge PR #340: Preserve exact channel formats when committing tracks
contributors/people.toml: union of both entries
2026-10-24 06:16:10 +00:00
aiongg c84bdda2f1 Merge PR #509: Validate the full WAVE_FORMAT_EXTENSIBLE fmt chunk (#465) 2026-10-24 06:16:08 +00:00
aiongg 886969bd19 Merge PR #631: Give the New Tracks name field its own row so it stays wide (#626) 2026-10-24 06:16:01 +00:00
SusieeTheLinuxUserandGLM-5.3-Flash 6c03d7e8de Tracks: clip track and group names to their rows in the side lists (fixes #990)
Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
2026-10-24 07:08:18 +02:00
Brandon Thomas ef646cf626 Merge pull request #1009 from storytold/fix/996-followup
audiosuite: saturating render budget, and a sharper budget test (follow-up to #996)
2026-10-24 01:03:54 -04:00
Brandon Thomas aa06d87410 Merge pull request #982 from Silvertree2010/fix/918-audit-the-output-nan-guard-cover
Guard all playback output paths against NaN from interpolation overflow
2026-10-24 01:03:50 -04:00
Brandon Thomas c422084a4d Merge pull request #994 from SusieeTheLinuxUser/fix/928-h264-idct-overflow
Video: clamp H.264 IDCT inputs and temporal-direct mvs to the spec ranges (#928)
2026-10-24 01:03:47 -04:00
Brandon Thomas 1779610626 Merge remote-tracking branch 'origin/main' into m/982
# Conflicts:
#	crates/playback/src/lib.rs
2026-10-24 01:02:27 -04:00
Brandon Thomas 7cec02403e Merge remote-tracking branch 'origin/main' into m/994
# Conflicts:
#	crates/video/src/h264/slicedec.rs
2026-10-24 01:02:19 -04:00
Brandon ThomasandClaude Opus 5.5 7ae583895d audiosuite: saturating budget product; the budget test expects the total-budget error
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:02:01 -04:00
Brandon Thomas b7fe88a72e Merge pull request #996 from sbacaro/fix/896-offline-render-budget
Bound offline render commands: dedupe tracks and a total sample budget (fixes #896)
2026-10-24 01:01:52 -04:00
Brandon Thomas 16c3e222b7 Merge pull request #983 from Silvertree2010/fix/908-audit-panic-rollback-is-partial
Restore all engine side-effect state when a command panics
2026-10-24 01:01:49 -04:00
Brandon Thomas 04d014b662 Merge pull request #981 from Silvertree2010/fix/920-audit-a-punch-that-misses-the-ta
Don't register a source for a take that a punch range misses
2026-10-24 01:01:45 -04:00
Brandon Thomas ffd88ee73d Merge pull request #868 from oracola91/fix/autosave-folders-owner-only
Autosave: the autosave folders are owner-only on Unix
2026-10-24 01:01:42 -04:00
Brandon Thomas 279d32080e Merge pull request #960 from rollroyces/fix/soundcraft-907-trim-saturating
fix(model): saturate the arithmetic in trim_start_to / trim_end_to (#907)
2026-10-24 01:01:38 -04:00
Brandon Thomas 6db7d29218 Merge pull request #679 from marcoarnulfo/feat/scrollintoview
Scroll Into View in the track name menu: the track goes to the top of the Edit window and the left of the Mix
2026-10-24 01:01:34 -04:00
Brandon Thomas 11aa5b99b3 Merge pull request #946 from storytold/test/backfill-regression-tests
Regression test for #264
2026-10-24 01:01:31 -04:00
Brandon Thomas 567172f530 Merge pull request #979 from marcoarnulfo/feat/repeatable-automation-roots
MCP: --automation-read-root and --automation-write-root may each be given more than once
2026-10-24 01:01:28 -04:00
Brandon Thomas 6a4bcadf1e Merge pull request #986 from Silvertree2010/fix/889-audit-a-latency-change-reallocat
Keep delay-line storage when the compensated latency changes
2026-10-24 01:01:24 -04:00
Brandon Thomas cdc07eef35 Merge pull request #980 from Silvertree2010/fix/929-audit-file-export-session-text-o
Require replace: true for file.export_session_text to overwrite
2026-10-24 01:01:20 -04:00
Brandon Thomas 0a1b1b185d Merge pull request #940 from Silvertree2010/fix/h1335-automation-id-panics-with-intege
Fix automation_id overflow and bad ids for high send/plugin slots
2026-10-24 01:01:17 -04:00
Brandon Thomas a0f78c0a1f Merge pull request #875 from Silvertree2010/fix/h1264-seconds-and-frames-grids-round-t
Place Seconds and Frames grid lines at exact multiples of the step
2026-10-24 01:01:13 -04:00
Brandon Thomas 03183ae308 Merge pull request #867 from Silvertree2010/fix/h1222-copy-range-keeps-fades-of-the-or
Reset fades at cut edges in copy_range
2026-10-24 01:01:09 -04:00
Brandon Thomas 58b4208e1f Merge pull request #833 from Silvertree2010/fix/h1140-smf-export-import-changes-note-l
Resolve same-pitch overlapping notes when writing SMF
2026-10-24 01:01:06 -04:00
Brandon Thomas 2ac0200c11 Merge pull request #824 from Silvertree2010/fix/h928-remote-control-client-rejects-ho
Resolve hostnames in remote control client targets
2026-10-24 01:01:02 -04:00
Brandon Thomas a6963aaeea Merge pull request #780 from Silvertree2010/fix/772-heal-separation-missing-behavior
Heal separations left by a cut, not only splits
2026-10-24 01:00:58 -04:00
Brandon Thomas 9a9da473c6 Merge pull request #947 from marcoarnulfo/fix/save-session-bare-name
Save Session: a bare file name is checked as the working directory against the write root
2026-10-24 01:00:53 -04:00
Brandon Thomas b7dda1433a Merge pull request #957 from justinjohn0306/codex/issue-897-h264-skip-run
Prevent H.264 skip-run bounds overflow
2026-10-24 01:00:49 -04:00
Brandon Thomas a19b67eb58 Merge pull request #653 from marcoarnulfo/fix/plugin-edits-dirty
Plugin-only editor changes mark the session modified
2026-10-24 01:00:46 -04:00
Brandon Thomas 3903e0a894 Merge pull request #573 from DCubix/fix/resampler-block-edge
Fix stale sample read at the end of each mix block when device and session rates differ
2026-10-24 01:00:42 -04:00
Brandon Thomas 0982bdedee Merge pull request #941 from zuujiscom/freebsd/aarch64-cross-package
FreeBSD: package the aarch64 cross build (craft-common-actions#39)
2026-10-24 01:00:38 -04:00
Brandon Thomas 62d2a47878 Merge pull request #977 from MarekSpodBiedry/issue-951
Follow the desktop light/dark setting for the System theme on Linux (#951)
2026-10-24 01:00:34 -04:00
Brandon Thomas 8e8157f550 Merge pull request #805 from mrrobbiec/perf/large-session-drawing
Edit window: draw large edited sessions without per-clip work every frame
2026-10-24 01:00:30 -04:00
dotCooCoo c2da4cf912 Add a built-in Ring Modulator plugin
The built-in plugins had no ring modulator, and AudioSuite > Modulation >
Sci-Fi ran the Flanger instead. Ring Modulator (`ring_mod`, Modulation
category, also offline as AudioSuite) has four effects: Ring multiplies the
input by a sine carrier, Frequency Mod reads the input through a delay the
carrier swings by 1 ms, and Resonator + and Resonator - feed the output back
after one period of the frequency with a positive or a negative sign. At full
depth an LFO or sample and hold moves the frequency by up to two octaves
either way, and an envelope follower raises it by up to two octaves.
AudioSuite > Modulation > Sci-Fi now opens it.

The Total row in docs/plugin-parity.md now counts 25 processors and 3
instruments, the entries in the built-in registry.
2026-10-23 21:56:01 -07:00
Brandon ThomasandClaude Opus 5.5 0888ca8ac3 Playback: an infinite sample goes out as silence, not full scale
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:55:05 -04:00
Brandon ThomasandClaude Opus 5.5 77d96affc3 Panic rollback test: start from non-default side-effect state
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:55:05 -04:00
Brandon Thomas a18f86cc89 Merge remote-tracking branch 'origin/main' into m/983 2026-10-24 00:54:53 -04:00
Brandon Thomas ad7b9bcac6 Merge remote-tracking branch 'origin/main' into m/982 2026-10-24 00:54:41 -04:00
Brandon Thomas 19f1069109 Merge remote-tracking branch 'origin/main' into m/981
# Conflicts:
#	crates/engine/src/io.rs
2026-10-24 00:54:37 -04:00
Brandon Thomas 5088846d45 Merge remote-tracking branch 'origin/main' into m/868
# Conflicts:
#	crates/engine/src/autosave.rs
2026-10-24 00:54:17 -04:00
Brandon ThomasandClaude Opus 5.5 b0f07331a2 Clip::trim_start_to keeps the end when an extreme start would overflow the length
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:53:41 -04:00
Brandon Thomas 1053073302 Merge remote-tracking branch 'origin/main' into m/960 2026-10-24 00:53:02 -04:00
Brandon Thomas a50fdf9cc9 Merge remote-tracking branch 'origin/main' into m/947 2026-10-24 00:53:01 -04:00
Brandon ThomasandClaude Opus 5.5 99579a5421 Move the Scroll Into View test into the shared tests/it binary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:51:56 -04:00
Brandon Thomas 0000566f5e Merge remote-tracking branch 'origin/main' into m/679 2026-10-24 00:51:38 -04:00
Brandon Thomas b8b9a90c6f Merge remote-tracking branch 'origin/main' into m/979 2026-10-24 00:50:13 -04:00
Brandon ThomasandClaude Opus 5.5 dfb3815eb3 Move the #264 toolbar scroll test into the shared tests/it binary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:50:07 -04:00
Brandon Thomas bf9fa3cc60 Merge remote-tracking branch 'origin/main' into m/946 2026-10-24 00:50:02 -04:00
Dylan M. Taylor 64669e9a7c chore: resolve SonarCloud maintainability issues (#1003)
* chore: resolve SonarCloud maintainability issues

* fix(packaging): extract verify_sha256 helper in package.sh

* style: apply rustfmt formatting

* fix(video): ensure prefix is i32 in cabac_mb
2026-10-23 22:33:59 -04:00
zuujiscomandClaude Opus 5.5 07b85277ee Merge main into freebsd/aarch64-cross-package
Brings in main's SonarCloud cleanup of package.sh and gives the cross-build changes the same style:
[[ ]] tests, and release_arch takes its argument as a named local and ends in an explicit return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:08:24 -05:00
lucasegenandClaude Opus 5.5 1f72dc30be Import registry in au-host's macOS api module
#988 replaced `use super::*;` in the macOS `api` module with an explicit
list that leaves `registry` out, so soundcraft-au-host, and with it the
app, no longer builds on macOS: four E0433 errors. Linux CI compiles this
module out, so it stayed green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:29:51 +02:00
Samuel Bacaro 525a4e02fe Bound offline render commands: dedupe tracks and a total sample budget
AudioSuite-style commands looped over tracks/clips and kept every
result as a new pool source (which undo snapshots also hold). Each
render was capped per-track, but:

- tracks_param did not remove duplicates, so listing the same track id
  eight times rendered it eight times;
- audiosuite.process's signal_generator branch checked only per track,
  so many distinct tracks could exceed memory in one command.

Fixes:
- tracks_param now dedupes track ids with a HashSet.
- generate() subtracts frames*channels from a MAX_RENDER_SAMPLES budget
  across all tracks and refuses with "too much audio to generate in one
  command" before allocating.

Regression tests cover duplicate track ids and the cross-track budget.

Fixes #896
2026-10-23 19:28:45 -03:00
SusieeTheFemboyandClaude Opus 5.5 8b34dc533f Video: derive temporal-direct mvL1 from the unclamped mvL0
mvL1 = mvL0 - mvCol (8.4.1.2.3) was computed from mvL0 after it had been
clamped to the motion-vector range, so a crafted stream with dsf 512 and
mvCol 32767 got mvL1 = 0 instead of 32767. Both vectors are now derived
in i32 and only the results are clamped; in-range streams are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:00:00 +02:00
SusieeTheFemboyandGLM-5.3-Flash 4b54d72c23 Video: clamp H.264 IDCT inputs and temporal-direct mvs to the spec ranges
The audit's L-27 (issue #928): scale4/scale8 and the DC dequantisations
deliberately wrap (huge levels must not panic there), so the values reaching
idct4_add/idct8_1d can be any i32, where the plain + / - of the butterfly
arithmetic panicked in debug and overflow-checks builds on crafted streams.
The spec leaves the inverse-transform output unspecified when a dequantised r
falls outside -2^15 to 2^15 - 1 (8.5.12.1, likewise 8.5.13.1), so the IDCT
now clamps its input to that range: bit-exact for every stream whose r stays
in range, defined instead of panicking or wrapping to garbage otherwise.

Temporal direct (8.4.1.2.1) had the same class of bug one layer up: the
mvL0 - mvCol subtraction overflowed i16 for extreme co-located mvs (stored
mvs wrap by design). The derivation moves into temporal_direct_mvs, runs in
i32 and clamps to the spec's -2^15 to 2^15 - 1 motion-vector range.

Regression tests feed i32::MIN/MAX coefficients into idct4_add/idct8_add and
i16 extremes through temporal_direct_mvs; they panicked with overflow before
the fix. Decoded output for well-formed streams is unchanged.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
2026-10-23 23:49:14 +02:00
Dylan M. Taylor 6b84a4f046 ci(sonar): don't cancel superseded runs on main, so coverage is published (#992) 2026-10-23 17:00:27 -04:00
aiongg 67865b23e8 Build: use craft-media for bitstream and isobmff (#776)
* Build: use craft-media for bitstream and isobmff

soundcraft-video re-exports craft_media::{bitstream, isobmff} from
craft-libs instead of carrying a copy of FilmCraft's crates. The module
paths stay, so crate::bitstream and crate::isobmff resolve as before.
The shared copy is newer: it adds APV, .mp2 as MPEG audio, AAC channel
configurations 11-14, SampleEntry::av1/flac, Mp4Writer::set_sample_entry
and the se(v) fix for i32::MIN.

* Build: pin craft-media to craft-libs f0ad6c1e
2026-10-23 16:41:42 -04:00
lucasegen e82de5cb97 Merge upstream main into fix/record-tooltip-f12 2026-10-23 22:22:02 +02:00
Dylan M. TaylorandDylan Taylor fe4bb3fe0c style: replace flagged glob imports with explicit uses (SonarCloud rust:S2208); move release workflow's write permission to the job (S8233) (#988)
* security: move the release workflow's write permission to the job level (SonarCloud githubactions:S8233)

* style: replace wildcard imports with explicit uses (SonarCloud rust:S2208)

Rewrites every glob `use` Sonar flags into explicit imports, so what each
command module consumes is visible at the top of the file.

* release: the git-sources gate moves into craft-common-actions (PR #78)

The check is shared at release.yml@main now; keep only the reusable call.

---------

Co-authored-by: Dylan Taylor <dylanmtaylor@users.noreply.github.com>
2026-10-23 14:53:00 -04:00
aiongg a4d3d8541f CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#955)
ci.yml is new: it calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD runs nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
2026-10-23 14:17:28 -04:00
aiongg e8abd44712 CI: one test binary per crate, nextest, line tables only (#985)
* Build: line tables only for workspace crates, no debug info for dependencies

Full debug info made most of the dev and test target size and link time. Line tables keep file and line numbers in backtraces; dependency frames lose them.

* Tests: build each crate's integration tests as one binary

Each tests/*.rs file was its own test binary: 98 binaries, each linking the crate and its dependencies. The files are now modules of tests/it/main.rs, one binary per crate, which cuts link time and target size. Filter by module: `cargo test -p <crate> --test it <module>::`.

Binaries with their own main (harness = false) in clap-host and vst3-host stay separate, as do the ui-egui tests that switch the process-wide Light/Dark palette and recovery.rs, whose autosave locks leak into child processes other tests start.

* Xtask: run tests with nextest, doctests apart, and no examples in the test build

cargo xtask ci runs the tests with cargo nextest when it is installed, else cargo test with a notice, and doctests with cargo test --doc. The test build leaves out examples (--lib --bins --tests); clippy --all-targets still compiles them. Test binaries with harness = false run under cargo test: their own main cannot answer nextest's --list. clippy and the wasm check keep going past a failing crate. cargo xtask test runs the test step alone.
2026-10-23 14:00:38 -04:00
NetterSilverandClaude Opus 5.5 cc493b2d8b Keep delay-line storage when the compensated latency changes
Fixes #889

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:56:02 +00:00
aiongg ab1849cbf8 Audio: check the WAVE behind a skipped RIFF form; hold egui at 0.36 in Dependabot (#978)
* Audio: check the WAVE behind a RIFF form the probe skips

The probe skips a RIFF form other than WAVE and reads a WAVE behind it, so
the marker search skips it too and the INFO check runs on the WAVE the
probe reads. The mismatched-container test moves to an input that still
mismatches: a hinted FLAC behind a WAVE.

* Dependabot: hold egui, eframe and egui_kittest at 0.36

craft-ui stays on egui 0.36 until storytold/craft-libs#199 lands; the daily
group would otherwise propose 0.37 again.
2026-10-23 13:37:39 -04:00
aiongg 265645a8fc Sessions: resolve stored source paths with craft-docpath's resolve_absolute (#826)
* Sessions: use resolve_absolute for stored source paths

* CI: check that git dependencies come from storytold

* CI: release only with git dependencies from storytold

* Parse Cargo.lock as TOML when checking git sources

* Use the shared git-sources check in CI

* CI: parse cargo config, including the legacy .cargo/config, in the git-sources check
2026-10-23 13:37:13 -04:00
Gert Pellin - GPE 9b68982738 autosave: give every session its own folder; keep the tests apart from coarse mtimes (#579) (#594)
With /tmp on tmpfs, four autosave and recovery tests failed:

- Autosave folders were named by millisecond and process id, and created
  with create_dir_all, so two sessions started in the same millisecond
  shared one folder and the second overwrote the first's unsaved work
  (recovering_keeps_the_unsaved_work_it_replaces,
  closing_the_session_or_quitting_drops_its_autosave). Autosave::create
  now creates the folder exclusively and adds a -2, -3... suffix when
  the name is taken.
- leftovers() offers an autosave only when it is strictly newer than its
  session file. A save and the autosave right after it can share one
  coarse mtime, so the tests that save and then autosave now move the
  saved file's mtime a second back
  (unsaved_work_is_offered_once_its_session_closes,
  an_exit_without_quit_offers_unsaved_changes_at_the_next_launch).
2026-10-23 13:36:57 -04:00
aiongg ef8a63f8c3 Packaging: pin the signing client and appimagetool, and check their hashes (#827)
* Packaging: pin the Windows signing client and check its hash

* Packaging: pin appimagetool and check its hash

* Reject AppImage download for architectures without a pinned appimagetool

* Pin the AppImage runtime
2026-10-23 13:35:58 -04:00
NetterSilverandClaude Haiku 5.5 9ecf197aa7 Restore all engine side-effect state when a command panics
Fixes #908

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 19:06:48 +02:00
NetterSilverandClaude Haiku 5.5 072b4ff3da Guard all playback output paths against NaN from interpolation overflow
Fixes #918

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 18:59:45 +02:00
NetterSilverandClaude Haiku 5.5 0235d11d4c Don't register a source for a take that a punch range misses
Fixes #920

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 18:56:25 +02:00
NetterSilverandClaude Haiku 5.5 767feacf72 Require replace: true for file.export_session_text to overwrite
Fixes #929

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 18:47:27 +02:00
Marco Arnulfo d01abd0e4e MCP: --automation-read-root and --automation-write-root may each be given more than once (fixes #874) 2026-10-23 18:25:05 +02:00
Superior f0d5acb800 Deps: fix the workspace build after the dependency bump (#954)
* Audio: migrate file decoding to symphonia 0.6

Upstream bumped symphonia to 0.6 without adapting crates/audio-io, so
`soundcraft-audio-io` no longer built: the probe module, codec parameters
and decoded-buffer APIs all moved.

- Probe with `default::get_probe().probe(&Hint, …)`. The 0.5 prober's
  detection-only path is gone, so scan for the container marker ourselves
  before instantiating a reader, and keep running the existing size checks
  (CAF/MP4/FLAC/WAV) on the first marker — the damaged-file regression
  tests depend on those checks firing before any allocation.
- Skip leading ID3v2 tags with `ReadBytes::ignore_bytes` as before.
- Read `Track::codec_params` (now an `Option`), decode through
  `make_audio_decoder` and `GenericAudioBufferRef::copy_to_vecs_planar`,
  and read the new public `Packet` fields.
- Keep the per-packet frame limit and the sample sanitisation.

* Playback: migrate the audio device layer to cpal 0.18

- Sample rates are plain `u32` now: drop `.0` and the `cpal::SampleRate`
  constructor.
- One `cpal::Error` replaces the build/stream/play/pause error types;
  detect `DeviceNotAvailable` through `error.kind()`.
- Stream builders take `StreamConfig` by value, and device names come
  from `Device::description()` (the deprecated `name()` is gone).
- The test `Fake` implements the new `StreamTrait::{buffer_size, now}`.

* Deps: pin egui back to 0.36 until craft-ui supports 0.37

The dependabot group bump (#767) moved the workspace to egui, eframe and
egui_kittest 0.37, but the pinned `craft-ui` revision still builds against
egui 0.36, so the graph carries two incompatible egui versions and
`soundcraft-ui-egui` does not compile. craft-libs#199 updates craft-ui to
0.37 and is still open; pin 0.36 and refresh the lockfile meanwhile. The
other version bumps from #767 stay.

Re-land egui 0.37 together with the craft-ui update.

* Audio: refuse a size-checked container the probe did not validate

The dependency-migration review showed the preflight size checks can
validate one container marker while symphonia's probe selects a different
container, so a misleading marker could skip the FLAC/MP4/CAF/WAVE checks.
Probe 0.6 reports the selected format only through the reader it returns,
so `open()` now records which container's checks ran (including the
RF64/BW64 RIFF forms) and refuses the stream when a size-checked reader
was chosen without its checks, before any packet is decoded. Markers found
in audio data of non-container streams (MP3, Ogg, AIFF) do not reject
valid files.

Adds a regression test with a fake RIFF prefix followed by a valid FLAC
stream, and doc comments for the functions touched by the dependency
migration (the review's docstring-coverage warning).
2026-10-23 12:15:56 -04:00
MarekSpodBiedry 7c690b19e1 Follow the desktop light/dark setting for the System theme on Linux (#951) 2026-10-23 17:44:09 +02:00
Dylan M. Taylor d47abe484d Use chrono instead of hand-rolled UTC conversion (#844)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the civil_from_days copy behind the desktop app's log timestamp with
chrono as a workspace dependency. The output format is unchanged and pinned by
the existing test. AGENTS.md now says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic logging on a clock outside chrono's range

chrono's From<SystemTime> unwraps internally, so a clock past year 262143
panicked inside the logger. timestamp now reads the duration since the epoch
and goes through the fallible DateTime::from_timestamp, reading an
out-of-range clock as the epoch, exactly as the pre-epoch clamp already did.
The year is written with {:04} rather than %Y, which would prefix a '+' past
year 9999. Tests cover the far-future and year-10000 cases.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve old out-of-range log timestamp (no epoch fallback)

`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any clock
past chrono's year range to 1970-01-01, changing output the old hand-rolled
`civil_from_days` converted exactly. Restore the old conversion domain with a
cycle-based helper that routes the calendar through chrono (the calendar
repeats every 400 years = 146_097 days, so month/day/time come from chrono and
only the year is carried in i128, keeping every i64 exact). Keep the pre-1970
epoch clamp; u64 seconds past i64::MAX saturate rather than falling back.

Pinned old outputs in the test (replacing the u64::MAX/2 -> epoch assertion):
  u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z
  10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-23 11:35:06 -04:00
Dylan M. Taylor f431791c26 style: use [[ ]], named locals and explicit returns in packaging scripts (SonarCloud S7688/S7679/S7682) (#950)
Bash scripts under packaging/ now use [[ ]] for tests, copy positional
parameters into named locals inside functions, and end env.sh helpers with
an explicit return (bare, so each keeps its exit status). shellcheck clean.
2026-10-23 11:17:44 -04:00
Dylan M. Taylor 53a18ec037 Merge pull request #962 from dylanmtaylor/sonar/security-reliability
style: normalize the prores DCT basis literals (SonarCloud rust:S7454)
2026-10-23 09:37:38 -04:00
Dylan M. Taylor 44067c8779 style: normalize the prores DCT basis literals (SonarCloud rust:S7454)
0.490_392_64 and 0.49039264 are the same f32 value; the underscored form
reads as a numeric suffix to the analyzer.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-23 09:29:32 -04:00
lucasegenandClaude Opus 5.5 24fa4668ef Name F12 in the Record button's tooltip
The tooltip only offered Cmd+Space, which macOS gives to Spotlight unless
the user frees it, so on a stock Mac the advertised shortcut opens
Spotlight. F12 already records (shortcuts.rs), as on the incumbent; the
tooltip now names it first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:08:15 +02:00
Royce ce235c6f80 fix(model): saturate the arithmetic in trim_start_to / trim_end_to (#907)
`Samples` is `i64`, and these two public APIs did plain subtraction:

```rust
let new_start = new_start.min(self.end() - 1);   // end() - 1
let delta = new_start - self.start;               // extreme new_start vs extreme start
self.length -= delta;                            // -=
let new_end = new_end.max(self.start + 1);       // start + 1
self.length = new_end - self.start;               // extreme new_end vs extreme start
```

Command callers clamp to `MAX_POSITION` first, so nothing in the app trips
this today — but the methods are `pub`, and a clip read from a hostile file
can already hold any `i64` before `sanitize` runs. An extreme `new_start`
against an extreme `start` wraps in release and panics in debug.

Each site becomes a saturating operation followed by the same clamp
`sanitize` already applies, so the result is identical for every input a
command can produce.

Test: `trims_saturate_on_extreme_arguments` sweeps `i64::MIN`/`MAX`,
`±2 * MAX_POSITION` and the edges, in both directions, asserting positions
stay within `±MAX_POSITION` and lengths within `1..=MAX_POSITION`.
Verified against the unfixed arithmetic: the test fails with
`attempt to subtract with overflow`.

cargo test -p soundcraft-model: 41 passed, 0 failed.
cargo clippy -p soundcraft-model --lib --no-deps --tests clean.
2026-10-23 20:09:26 +08:00
justinjohn0306 811d78729d Prevent H264 skip-run bounds overflow 2026-10-23 15:55:15 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 52471b37e9 chore(deps): bump the cargo-dependencies group across 1 directory with 11 updates (#767)
Bumps the cargo-dependencies group with 11 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [serde_json](https://github.com/serde-rs/json) | `1.0.151` | `1.0.154` |
| [proptest](https://github.com/proptest-rs/proptest) | `1.11.0` | `1.12.0` |
| [symphonia](https://github.com/pdeljanov/Symphonia) | `0.5.5` | `0.6.1` |
| [zip](https://github.com/zip-rs/zip2) | `9.0.1` | `9.0.5` |
| [cpal](https://github.com/RustAudio/cpal) | `0.16.0` | `0.18.2` |
| [egui](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [eframe](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [egui_kittest](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [libloading](https://github.com/nagisa/rust_libloading) | `0.8.9` | `0.9.0` |
| [muda](https://github.com/tauri-apps/muda) | `0.19.3` | `0.21.2` |
| [winresource](https://github.com/BenjaminRi/winresource) | `0.1.31` | `0.1.34` |



Updates `serde_json` from 1.0.151 to 1.0.154
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154)

Updates `proptest` from 1.11.0 to 1.12.0
- [Release notes](https://github.com/proptest-rs/proptest/releases)
- [Changelog](https://github.com/proptest-rs/proptest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/proptest-rs/proptest/compare/v1.11.0...v1.12.0)

Updates `symphonia` from 0.5.5 to 0.6.1
- [Release notes](https://github.com/pdeljanov/Symphonia/releases)
- [Commits](https://github.com/pdeljanov/Symphonia/compare/v0.5.5...v0.6.1)

Updates `zip` from 9.0.1 to 9.0.5
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v9.0.1...v9.0.5)

Updates `cpal` from 0.16.0 to 0.18.2
- [Release notes](https://github.com/RustAudio/cpal/releases)
- [Changelog](https://github.com/RustAudio/cpal/blob/master/CHANGELOG.md)
- [Commits](https://github.com/RustAudio/cpal/compare/v0.16.0...v0.18.2)

Updates `egui` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `eframe` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `egui_kittest` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `libloading` from 0.8.9 to 0.9.0
- [Commits](https://github.com/nagisa/rust_libloading/compare/0.8.9...0.9.0)

Updates `muda` from 0.19.3 to 0.21.2
- [Release notes](https://github.com/tauri-apps/muda/releases)
- [Changelog](https://github.com/tauri-apps/muda/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/muda/compare/muda-v0.19.3...muda-v0.21.2)

Updates `winresource` from 0.1.31 to 0.1.34
- [Commits](https://github.com/BenjaminRi/winresource/commits)

---
updated-dependencies:
- dependency-name: cpal
  dependency-version: 0.18.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: eframe
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: egui
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: egui_kittest
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: libloading
  dependency-version: 0.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: muda
  dependency-version: 0.21.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: proptest
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: serde_json
  dependency-version: 1.0.154
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: symphonia
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: winresource
  dependency-version: 0.1.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: zip
  dependency-version: 9.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 23:36:57 -04:00
skylar taylor-barrick 44663d9601 #264: require the whole tempo field visible and unclipped
The test only checked the tempo text's left edge, so a field cut off on either side would still pass. It now checks that the whole text lies inside the window and inside the clip rect it is painted with. With the toolbar's scrolling turned off it still fails.
2026-10-22 19:13:26 -06:00
Marco Arnulfo 9049eb53c5 Save Session: a bare file name is checked as the working directory against the write root (fixes #869) 2026-10-23 02:08:38 +02:00
skylar taylor-barrick a36228f0ec #264: test that the Edit toolbar scrolls horizontally in a narrow window
The Edit toolbar laid its tool groups out in a bare row, so in a narrow window the groups on the right (the tempo among them) were clipped and unreachable. The test renders a 700 pt wide window, scrolls the toolbar sideways with the wheel and checks the tempo field is then painted inside the window. Without the horizontal ScrollArea the tempo is never painted, so the assertion fails.
2026-10-22 18:02:15 -06:00
zuujiscom d9f2b070cb FreeBSD package.sh: accept only the two supported FreeBSD targets
Review follow-up. CARGO_BUILD_TARGET must be aarch64-unknown-freebsd or x86_64-unknown-freebsd,
the two the release builds; anything else, including made-up triples such as
aarch64-foo-unknown-freebsd, stops with exit 2.

Part of storytold/craft-common-actions#39.
2026-10-22 16:06:19 -05:00
zuujiscom 38d62661ce FreeBSD package.sh: never run a FreeBSD target's binary on another OS
Review follow-up. With CARGO_BUILD_TARGET set on a host that is not FreeBSD (say an x86_64 Mac
building x86_64-unknown-freebsd), the architectures match but the binary still cannot run, so
the --version run is skipped there and the file(1) check is used instead.

Part of storytold/craft-common-actions#39.
2026-10-22 15:31:37 -05:00
Brandon ThomasandClaude Opus 5.5 e85a4801e9 Release 0.12.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:16:17 -04:00
Brandon ThomasandClaude Opus 5.5 239c78e3a0 macOS packaging: retry stapler when Apple's CloudKit drops the connection
stapler staple/validate fetch the notarization ticket from CloudKit, which
sometimes fails with "The network connection was lost" (Error 68). That
failed this release's macOS job twice in a row. Both steps are idempotent,
so retry up to 5 times with backoff, like hdiutil_retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:16:16 -04:00
zuujiscom 8063aa7173 FreeBSD package.sh: accept only executables in the cross-build check
Review follow-up. The file(1) check for binaries that cannot run here now needs an executable
(file says "executable" or "pie executable") or an ELF with an interpreter, which is how an older
file(1) still shows a PIE it calls a shared object. FreeBSD libraries, object files and core files
for the target architecture are refused.

Part of storytold/craft-common-actions#39.
2026-10-22 14:49:35 -05:00
Brandon ThomasandClaude Opus 5.5 2cfc56d515 Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:38:08 -04:00
zuujiscom ce42466dd6 FreeBSD package.sh: accept only FreeBSD targets, check cross-built binaries
Review follow-up. CARGO_BUILD_TARGET must now be a *-unknown-freebsd triple, so a Linux or macOS
target can no longer be packaged under a FreeBSD name, and it is read before uname -m, so an
unsupported host does not block a valid cross build. When the binaries are for another
architecture and cannot run --version, file(1) must report each as a FreeBSD ELF executable
for the target architecture.

Part of storytold/craft-common-actions#39.
2026-10-22 14:32:12 -05:00
zuujiscom 79adead5bf FreeBSD package.sh: package the aarch64 cross build
The release workflow now cross-compiles FreeBSD aarch64 inside the x86_64 VM
(storytold/craft-common-actions#50) and sets CARGO_BUILD_TARGET=aarch64-unknown-freebsd.
package.sh now reads the binaries from target/<triple>/release when CARGO_BUILD_TARGET
is set, names the archive after the target instead of uname -m, and skips the --version
run when the binary is for another architecture than the machine. x86_64 packages are
unchanged.

Part of storytold/craft-common-actions#39.
2026-10-22 14:14:07 -05:00
NetterSilverandClaude Haiku 5.5 a966917c76 Fix automation_id overflow and bad ids for high send/plugin slots
Found while testing crates/ui-egui/src/edit_window.rs:2474-2477

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:14 +02:00
skylar taylor-barrick 06db0bd373 Merge pull request #526 from saadkhantareen/fix/length-prefixed-nals-zero-width
fix(video): reject zero/invalid NAL length widths in length_prefixed_nals (#469)
2026-10-22 07:49:15 -06:00
skylar taylor-barrick 6c596fca13 Merge pull request #702 from Silvertree2010/fix/697-random-edit-test-pad-playlist-no
Keep playlist sorted after delete_fades trims overlapping clips
2026-10-22 07:49:07 -06:00
skylar taylor-barrick 5cdc72a0ed Merge pull request #786 from MichaelMuClaude/fix/plus-key-nudge-734
fix(ui): bare "=" key nudges the selected clip right, matching "-"
2026-10-22 07:49:00 -06:00
skylar taylor-barrick 589608a5d3 Merge pull request #812 from Silvertree2010/fix/h840-automationlane-write-range-overw
Keep existing breakpoint at end + 1 in AutomationLane::write_range
2026-10-22 07:48:53 -06:00
skylar taylor-barrick 55d9e8f86d Merge pull request #820 from Silvertree2010/fix/h907-dsp-offline-normalize-turns-near
Fix normalize producing inf samples for subnormal peaks
2026-10-22 07:48:47 -06:00
skylar taylor-barrick 84495f1c9b Merge pull request #829 from Silvertree2010/fix/816-mix-send-to-an-unknown-bus-name
Reject mix.send to an unknown bus name instead of creating a bus
2026-10-22 07:48:40 -06:00
skylar taylor-barrick 095b483ce1 Merge pull request #828 from MMexicann/fix/timecode-frame-count-overflow
Fix overflow in extreme timecode frame counts
2026-10-22 07:48:33 -06:00
skylar taylor-barrick 816a6efb77 Merge pull request #830 from MMexicann/fix/midi-invalid-smpte-division
Reject undefined SMPTE frame rates in MIDI headers
2026-10-22 07:48:26 -06:00
skylar taylor-barrick 1b3e87c9c0 Merge pull request #831 from Silvertree2010/fix/h1126-tempomap-bar-beat-at-tick-panics
Fix integer overflow in TempoMap::bar_beat_at_tick for i64::MIN
2026-10-22 07:48:19 -06:00
skylar taylor-barrick 2a5d308c55 Merge pull request #834 from Silvertree2010/fix/h1179-autoparam-label-panics-with-inte
Fix integer overflow in AutoParam::label for Pan(255)
2026-10-22 07:48:13 -06:00
BFlat b1466e451c Merge pull request #934 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:02:28 +07:00
bflatastic 24543e1486 README: the app icon above the title 2026-10-22 17:58:22 +07:00
oracola91 d49e114d84 Autosave: the folder takes are written into is owner-only
arm_recorder made a never-saved session's take folder owner-only, but
the recorder then made Audio Files inside it with create_dir_all. In a
take folder an earlier version left with a wider mode, new takes were
readable. arm_recorder now makes the folder each take goes into
owner-only.
2026-10-22 13:48:00 +03:00
NetterSilverandClaude Opus 5.5 da26cf832a Place Seconds and Frames grid lines at exact multiples of the step
Found while testing crates/time/src/grid.rs:127-128,158-161

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:43:56 +00:00
oracola91 5029626f9e Autosave: the autosave folders are owner-only on Unix
Autosaves and the takes of a session never saved are unsaved work. Their
folders were made with create_dir_all, so on Unix they got the default
mode and other accounts on the machine could read them. The folders the
autosaver creates, and the folder a never-saved session records into,
are now 0o700. A folder that already exists keeps its mode.

The same change as storytold/pdfcraft#3320 and storytold/vectorcraft#3406.
2026-10-22 11:03:38 +03:00
NetterSilverandClaude Opus 5.5 590ac97e93 Reset fades at cut edges in copy_range
Found while testing crates/engine/src/edit.rs:157-174

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 09:54:38 +02:00
Dylan M. Taylor 9b8283b908 security: enforce HTTPS on curl downloads (SonarCloud S6506) (#860)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 20:13:52 -04:00
Dylan M. Taylor 30ef8306f8 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:49 -04:00
Dylan M. Taylor 7587ae2690 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:47:06 -04:00
Brandon ThomasandClaude Opus 5.5 b74fca0fb0 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:57:33 -04:00
Paul G 8198d6e35a Merge remote-tracking branch 'upstream/main' into fix/597-import-off-ui-thread
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-21 04:14:28 -07:00
Limestudio90 1c1df8728d Merge remote-tracking branch 'origin/main' into vst3-host-messages 2026-10-21 11:53:38 +02:00
Marco Arnulfo 52253e89c5 Autosave a plugin editor gesture that is still open
The state of a plugin whose editor gesture hasn't ended is not in the
session yet, so the session looked clean: the two-minute autosave and
closing the session skipped it. Both now count a pending editor state as
a change to keep, and read the plugins' states as they do for a modified
session.
2026-10-21 11:30:15 +02:00
Marco Arnulfo 63eacf115d Read a plugin editor's state once per gesture, not every frame
The state of an instrument edited in its editor (or of a plugin that
marked its state dirty) was read and stored on every frame with a change,
so a drag serialized the plugin and resent the session each frame. Each
plugin's state now waits until its gesture ends: the first frame its
editor reports no change, or its editor closing. A plugin that keeps
changing doesn't hold back the others.
2026-10-21 10:50:19 +02:00
Marco Arnulfo 76bac6495e Scroll Into View: a Mix request lasts one frame, so a hidden track doesn't scroll the Mix when it comes back 2026-10-21 10:23:03 +02:00
Marco Arnulfo 16371a8502 Scroll Into View is a UI command, view.scroll_into_view, and the track name menu dispatches it 2026-10-21 10:23:03 +02:00
Marco Arnulfo 717ae97af5 Scroll Into View in the track name menu: the track goes to the top of the Edit window and the left of the Mix 2026-10-21 10:17:23 +02:00
NetterSilverandClaude Haiku 5.5 01c8c85ebc Fix integer overflow in AutoParam::label for Pan(255)
Found while testing crates/model/src/automation.rs:73

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 07:12:50 +02:00
NetterSilverandClaude Haiku 5.5 187ee51ae0 Resolve same-pitch overlapping notes when writing SMF
Found while testing crates/midi/src/smf.rs:486-493

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 02:30:10 +02:00
NetterSilverandClaude Haiku 5.5 4ef6458e4e Fix integer overflow in TempoMap::bar_beat_at_tick for i64::MIN
Found while testing crates/time/src/tempo.rs:271

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 01:12:42 +02:00
Mex 152c2c45e6 Reject undefined SMPTE frame rates in MIDI headers
Accept only the four defined SMPTE frame-rate codes. Add exhaustive invalid-code rejection and valid-rate timing and zero-tick regressions.
2026-10-20 23:51:29 +02:00
NetterSilverandClaude Haiku 5.5 96130d22c7 Reject mix.send to an unknown bus name instead of creating a bus
Fixes #816

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:30:04 +00:00
Mex 988d678eec Fix overflow in extreme timecode frame counts
Widen the magnitude and drop-frame label arithmetic to i128 so i64::MIN stays exact and extreme drop-frame counts cannot overflow. Add round-trip regressions for all frame rates and both drop-frame variants.
2026-10-20 21:50:38 +02:00
NetterSilverandClaude Haiku 5.5 797aaac058 Resolve hostnames in remote control client targets
Found while testing crates/automation/src/lib.rs:100

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 18:43:18 +02:00
Brandon ThomasandClaude Opus 5.5 7b339fe921 Skip CodeRabbit reviews on Dependabot PRs (#821)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:40:20 -04:00
NetterSilverandClaude Haiku 5.5 5703db0b80 Fix normalize producing inf samples for subnormal peaks
Found while testing crates/dsp/src/offline.rs:62-79

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 17:24:47 +02:00
NetterSilverandClaude Haiku 5.5 1816f2dc92 Keep existing breakpoint at end + 1 in AutomationLane::write_range
Found while testing crates/model/src/automation.rs:190-203

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 14:30:22 +02:00
mrrobbiecandClaude Opus 5.5 09c2e71141 Edit window: draw large edited sessions without per-clip work every frame
On a 63,424-clip drum edit the UI thread spent ~187 ms per frame, so with a plugin editor open
(a frame every 30 ms) it never went idle and the editor windows, which Windows only paints
when the thread's queue is empty, stopped redrawing. Now ~11 ms of CPU per frame:

- Overlap shadows: each drawn clip checked every clip of its track; on a sorted track the
  clips starting inside it are found by binary search (sortedness is checked once per track
  and frame, and an unsorted track is still scanned).
- Fade curves: a fade took at least 65 points, even under a pixel wide. A narrow one takes
  ~4 * its width in physical pixels; the chord error stays under a quarter pixel (tested).
- Clip List: rows are listed and filtered once per document revision and filter text, and
  only the rows in view are laid out (show_rows).
- Universe: each track's clips are merged into same-colour runs covering exactly the same
  pixels, cached per revision and area (5,000 clips become a handful of rects).
- Session::content_end, which walks every clip, is worked out once per revision.
- Alternate playlist lanes skip clips outside the view, like the main lane, and clip names
  are not laid out where no glyph would show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:01:25 +10:00
Diego LopesandClaude Opus 5.5 dfde8a6724 Add regression test for the resampler block edge
Render a long DC block, then short blocks of silence with the device
rate below the session rate. The interpolation must not read the stale
frame past pending_len. Also correct the comment on the edge hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:36:00 -04:00
Diego Lopes 0d8a92f53b Merge branch 'main' into fix/resampler-block-edge 2026-10-19 23:31:07 -04:00
Brandon ThomasandClaude Opus 5.5 e8940766d7 Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:17:25 -04:00
MarekSpodBiedry b5bcf577b2 Fix saved audio trust test on Windows by joining path parts separately (#789) 2026-10-19 22:27:44 +02:00
Matthew Burlage 4eee6b17a8 Preserve finite Float32 headroom during audio encoding 2026-10-19 12:58:35 -04:00
Limestudio90andClaude Opus 5.5 82616dcfb8 vst3-host: restore "crate" in the ffi module doc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:01:38 +02:00
Limestudio90 5ddd4b115f Merge remote-tracking branch 'origin/main' into vst3-host-messages
# Conflicts:
#	ROADMAP.md
#	docs/plugin-parity.md
2026-10-19 16:54:24 +02:00
Gert Pellin c8f8fc45eb convert: dither only when the output loses resolution (#548)
soundcraft-cli convert always passed dither: true, so a same-depth or
wider conversion still added ±1 LSB TPDF noise and digital silence came
out as noise. It now dithers only when writing integer samples that
cannot hold the source exactly: fewer bits than the source, a float or
lossy source, or samples changed by --rate/--normalize. 32-bit float
output never dithers. --dither and --no-dither override the choice,
like the Bounce option in #551.
2026-10-19 16:34:26 +02:00
MichaelandClaude Sonnet 5 9ac264184e fix(ui): bare "=" key nudges the selected clip right, matching "-"
`edit.nudge`'s "Plus/Minus" shortcut only matched `Key::Plus`, which a
real keyboard without a dedicated numpad `+` can never send: typing
"+" needs Shift, and Shift+=/- is already a separate, intentional
shortcut (Grid value). So nudge-right was unreachable on most
keyboards, while nudge-left worked fine ("-" needs no shift). Fixes
#736.

Add `Key::Equals` (the bare, unshifted key) to the nudge match arm,
the same way it already works for `Key::Minus`. Also exclude `m.alt`
from the arm, since `Alt+=` is the separate, already-registered
`window.event_list` shortcut that would otherwise be shadowed.

Verified live against a running demo session over the control channel
before writing the fix (plain "-" moved a clip; plain "=" and
Shift+"=" both did nothing), and again after. New regression test
covers bare Equals/Plus/Minus nudging, Shift+Equals still stepping the
Grid value instead, and Alt+Equals still opening the MIDI Event List
instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 20:01:40 +08:00
NetterSilverandClaude Haiku 5.5 3c04a3c36f Fix empty SMF export re-importing as one phantom empty track
Found while testing crates/midi/src/smf.rs:207

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:04:51 +00:00
NetterSilverandClaude Haiku 5.5 278b41b8ff Heal separations left by a cut, not only splits
Fixes #772

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:18:28 +00:00
NetterSilverandClaude Haiku 5.5 c09dab9fef fix: heal split halves of stretched audio clips
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 07:29:53 +00:00
Brandon Thomas 25d52c646a Merge pull request #779 from storytold/fix/fmt-clip-group-test
Fix main: rustfmt the clip group overlap test
2026-10-19 03:14:49 -04:00
Brandon ThomasandClaude Opus 5.5 9d53e4d97a Fix main: rustfmt the clip group overlap test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 03:14:44 -04:00
Brandon Thomas 3776e3338f Merge pull request #719 from reoniazx/fix/mono-device-fold-down
Fix mono device playback dropping right-channel audio
2026-10-19 03:10:04 -04:00
Brandon Thomas bec2d06ac6 Merge remote-tracking branch 'origin/main' into m/719
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
2026-10-19 03:09:57 -04:00
Brandon Thomas 01622f15bd Merge pull request #672 from reoniazx/fix/failed-insert-latency
Fix stale aux and master latency after insert failures
2026-10-19 03:09:55 -04:00
Brandon Thomas 6d268d5623 Merge remote-tracking branch 'origin/main' into m/672
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
2026-10-19 03:09:47 -04:00
Brandon Thomas 60f4342697 Merge pull request #500 from Silvertree2010/fix/sampler-pingpong-phase
Sampler: a ping-pong loop keeps its phase for steps wider than the loop (#463)
2026-10-19 03:09:45 -04:00
Brandon Thomas fdb2ea6504 Merge remote-tracking branch 'origin/main' into m/500
# Conflicts:
#	ROADMAP.md
2026-10-19 03:09:38 -04:00
Brandon Thomas 93d3cbfecc Merge pull request #705 from Silvertree2010/fix/h2479-event-extract-chords-panics-with
Saturate note tick addition in event.extract_chords
2026-10-19 03:09:29 -04:00
Brandon Thomas dea681cd65 Merge pull request #683 from Silvertree2010/fix/h2187-markers-add-ignores-start-when-e
Honour start or end given alone in markers.add
2026-10-19 03:09:26 -04:00
Brandon Thomas 42ceeaf02f Merge pull request #628 from Silvertree2010/fix/h1620-zero-frame-flac-written-by-our-o
Decode and probe zero-frame FLAC files written by our encoder
2026-10-19 03:09:22 -04:00
Brandon Thomas 5b9f6e5028 Merge pull request #498 from Silvertree2010/fix/wav-empty-data-chunk
WAV: an empty data chunk followed by metadata has no frames (#466)
2026-10-19 03:09:19 -04:00
Brandon Thomas b537e82bd9 Merge pull request #667 from reoniazx/fix/master-fader-automation-boundaries
Fix master fader automation leaking across render-block boundaries
2026-10-19 03:09:16 -04:00
Brandon Thomas 4c9a036f95 Merge pull request #695 from Silvertree2010/fix/h2384-automation-clear-with-a-misspell
Reject unknown param in automation.clear instead of clearing all lanes
2026-10-19 03:09:13 -04:00
Brandon Thomas 90a2c386dd Merge pull request #693 from Silvertree2010/fix/h2253-mix-volume-delta-db-is-applied-t
Apply mix.volume once to each member of a targeted mix group
2026-10-19 03:09:09 -04:00
Brandon Thomas d8000e9756 Merge pull request #515 from MarekSpodBiedry/issue-452
Keep overlapping clips of a clip group when importing it (#452)
2026-10-19 03:09:05 -04:00
Brandon ThomasandClaude Opus 5.5 9d3d02dbe7 clip group import test: check positions relative to the insertion point
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 03:08:37 -04:00
Brandon Thomas 8ae491b6d2 Merge remote-tracking branch 'origin/main' into m/628
# Conflicts:
#	crates/audio-io/tests/roundtrip.rs
2026-10-19 03:04:59 -04:00
Brandon Thomas 412af03dbf Merge remote-tracking branch 'origin/main' into m/719
# Conflicts:
#	ROADMAP.md
2026-10-19 03:04:41 -04:00
Brandon Thomas 81e4c5c1ec Merge remote-tracking branch 'origin/main' into m/498
# Conflicts:
#	ROADMAP.md
2026-10-19 03:04:41 -04:00
Brandon Thomas a8fa1e53d3 Merge remote-tracking branch 'origin/main' into m/672
# Conflicts:
#	ROADMAP.md
2026-10-19 03:04:40 -04:00
Brandon Thomas 798ee7d88f Merge remote-tracking branch 'origin/main' into m/667
# Conflicts:
#	ROADMAP.md
2026-10-19 03:04:40 -04:00
Brandon Thomas 3a57a0edc8 Merge remote-tracking branch 'origin/main' into m/695
# Conflicts:
#	crates/engine/src/cmd/mix.rs
2026-10-19 03:04:12 -04:00
Brandon Thomas 12c224aa66 Merge remote-tracking branch 'origin/main' into m/693
# Conflicts:
#	crates/engine/src/cmd/mix.rs
2026-10-19 03:04:12 -04:00
Brandon Thomas 9c8afc1cf0 Merge remote-tracking branch 'origin/main' into m/515
# Conflicts:
#	crates/engine/src/clip_group_file.rs
2026-10-19 03:04:12 -04:00
Brandon Thomas e9cda9000d Merge remote-tracking branch 'origin/main' into m/500 2026-10-19 03:04:01 -04:00
Brandon Thomas d81a6c2d49 Merge remote-tracking branch 'origin/main' into m/705 2026-10-19 03:04:01 -04:00
Brandon Thomas 95ce47dbc6 Merge remote-tracking branch 'origin/main' into m/683 2026-10-19 03:04:01 -04:00
Brandon Thomas 16be837ebc Merge pull request #578 from Silvertree2010/fix/h773-automation-copy-to-send-silently
Reject invalid send slot in automation.copy_to_send
2026-10-19 03:03:52 -04:00
Brandon Thomas a17dc98023 Merge pull request #703 from Silvertree2010/fix/h2459-mix-pan-and-mix-trim-silently-re
Reject missing or non-numeric values in mix.pan and mix.trim
2026-10-19 03:03:49 -04:00
Brandon Thomas c62d2533b7 Merge pull request #696 from Silvertree2010/fix/h2407-fader-knob-drags-stop-collapsing
Keep merging fader gestures once the undo history is full
2026-10-19 03:03:45 -04:00
Brandon Thomas 8a06cee05d Merge pull request #602 from Silvertree2010/fix/h1059-mix-insert-and-mix-send-silently
Reject invalid slot in mix.insert and mix.send
2026-10-19 03:03:40 -04:00
Brandon Thomas 8e196cfe2e Merge pull request #532 from nanox333/fix/440-stream-start-failure
fix(playback): retry the next output config when a stream won't start (#440)
2026-10-19 03:03:36 -04:00
Brandon Thomas 7694771a5e Merge pull request #523 from MarekSpodBiedry/issue-442
Reset clip effect EQ/compressor state on stop and seek (#442)
2026-10-19 03:03:33 -04:00
Brandon Thomas 582bfb2f7e Merge pull request #423 from webdevtodayjason/thin-step-lanes
Keep mute changes in place when thinning automation
2026-10-19 03:03:29 -04:00
Brandon Thomas b042a73dbf Merge pull request #666 from marcoarnulfo/feat/remove-sync-point
Clip > Remove Sync Point takes the sync point off the selected clips
2026-10-19 03:03:26 -04:00
Brandon Thomas 99d50d9811 Merge pull request #524 from MarekSpodBiedry/issue-426
Create session backups exclusively and stop when backup numbers run out (#426)
2026-10-19 03:03:23 -04:00
Brandon Thomas 979efff8a9 Merge pull request #520 from MarekSpodBiedry/issue-446
Fix bundle opening losing audio for sources that share one file (#446)
2026-10-19 03:03:19 -04:00
Brandon Thomas efbb5d9168 Merge pull request #516 from MarekSpodBiedry/issue-451
Reset clip stretch after AudioSuite bakes it into the new source (#451)
2026-10-19 03:03:15 -04:00
Brandon Thomas ef00e8322c Merge pull request #514 from MarekSpodBiedry/issue-453
Scale fades, sync point and gain envelope when importing a clip group at another sample rate (#453)
2026-10-19 03:03:11 -04:00
Brandon Thomas ef4a4a0a9a Merge pull request #513 from MarekSpodBiedry/issue-454
Keep controller-only tracks when importing MIDI (#454)
2026-10-19 03:03:07 -04:00
Brandon Thomas 603d62761a Merge pull request #494 from Jafz2001/fix/remove-folder-bus
fix(routing): remove folder bus on delete
2026-10-19 03:03:02 -04:00
Brandon Thomas f3cc778673 Merge pull request #537 from saadkhantareen/fix/464-poison-stream-encoders
fix(audio-io): poison stream encoders after a failed write so later calls cannot claim success (#464)
2026-10-19 03:02:58 -04:00
Brandon Thomas e5e0d860f3 Merge pull request #512 from MarekSpodBiedry/issue-458
Do not retry remote-control commands that may already have run (#458)
2026-10-19 03:02:54 -04:00
Brandon Thomas afb8e10ff2 Merge pull request #510 from MarekSpodBiedry/issue-462
Keep audio-io stream test limits as u64 so it compiles on 32-bit targets (#462)
2026-10-19 03:02:51 -04:00
Brandon Thomas b337801c66 Merge pull request #508 from MarekSpodBiedry/issue-467
Return TooLarge when the WAV header size overflows u64 (#467)
2026-10-19 03:02:47 -04:00
Brandon Thomas 00315b6fb4 Merge pull request #507 from MarekSpodBiedry/issue-468
Count the partial quartet in the base64 decoded-size cap (#468)
2026-10-19 03:02:43 -04:00
Brandon Thomas a593ca0130 Merge pull request #504 from MarekSpodBiedry/issue-471
Reject invalid numeric prereleases and restore Cargo.toml when version update fails (#471)
2026-10-19 03:02:39 -04:00
Brandon Thomas 5e94c0503f Merge pull request #483 from justinjohn0306/fix/472-midi-writer-clamp
MIDI writer: clamp out-of-range pitches and controller numbers (#472)
2026-10-19 03:02:34 -04:00
Brandon ThomasandClaude Opus 5.5 f32b07a646 Merge origin/main; docs: move the #463 entry to the progress log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:56:38 -04:00
Brandon Thomas 461520dca6 Merge remote-tracking branch 'origin/main' into fix/705
# Conflicts:
#	crates/engine/src/cmd/event_more.rs
2026-10-19 02:56:20 -04:00
Brandon Thomas 6258b15d8a Merge remote-tracking branch 'origin/main' into fix/683
# Conflicts:
#	crates/engine/src/cmd/event.rs
2026-10-19 02:56:08 -04:00
Brandon Thomas 18409ed52b Merge pull request #657 from marcoarnulfo/fix/track-group-rows-a11y
Tracks and Groups list rows publish their names to AccessKit
2026-10-19 02:55:04 -04:00
Brandon Thomas d67961a402 Merge pull request #655 from Audiosyncrasy/modify-group-dialog
Modify Group: edit a group's name, tracks and Edit/Mix linking
2026-10-19 02:55:00 -04:00
Brandon Thomas 7b8b69f5d3 Merge pull request #647 from marcoarnulfo/credits/marcoarnulfo
Contributors: add Marco Arnulfo
2026-10-19 02:54:57 -04:00
Brandon Thomas 8385c22616 Merge pull request #646 from marcoarnulfo/fix/web-open-dirty-session
Ask before a session opened in the browser replaces a modified one
2026-10-19 02:54:53 -04:00
Brandon Thomas bfd9bfbb89 Merge pull request #620 from marcoarnulfo/fix/automation-breakpoint-hits
Drag automation breakpoints and Alt-click to delete them in the Edit window
2026-10-19 02:54:50 -04:00
Brandon Thomas 14967a61bf Merge pull request #495 from rollroyces/fix/soundcraft-45-fedora-font-paths
fix(ui): add Fedora font paths so DejaVu and Liberation load on Fedora (#45)
2026-10-19 02:54:47 -04:00
Brandon Thomas 1d8e879c96 Merge pull request #479 from justinjohn0306/feat/268-portable-mode
Portable mode: portable.txt beside the executable keeps settings next to it (#268)
2026-10-19 02:54:43 -04:00
Brandon Thomas 06a303954e Merge pull request #425 from justinjohn0306/fix/374-instrument-menu
Track › Instrument: choose an instrument from the menu bar (#374)
2026-10-19 02:54:40 -04:00
Brandon Thomas 4e932785fe Merge pull request #645 from Silvertree2010/fix/h1875-track-write-midi-rtp-ignores-the
Shift controller events by Delay in track.write_midi_rtp
2026-10-19 02:54:37 -04:00
Brandon Thomas b96379e443 Merge pull request #633 from Silvertree2010/fix/h1763-markers-edit-ignores-its-documen
Make markers.edit apply its documented color parameter
2026-10-19 02:54:34 -04:00
Brandon Thomas 9d474a5388 Merge pull request #630 from Silvertree2010/fix/h1665-event-step-input-at-the-boundary
Fix step_input extending a MIDI clip over the adjacent next clip
2026-10-19 02:54:30 -04:00
Brandon Thomas 47e1b0e523 Merge pull request #629 from Silvertree2010/fix/h1599-session-with-a-meter-change-near
Drop out-of-range tempo and meter events when sanitizing a session
2026-10-19 02:54:27 -04:00
Brandon Thomas ef106bff24 Merge pull request #608 from Silvertree2010/fix/h1403-event-retrospective-record-overf
Clamp retrospective record start_ticks to stay within MAX_POSITION
2026-10-19 02:54:23 -04:00
Brandon Thomas 6bb5fbc978 Merge pull request #588 from Silvertree2010/fix/h851-min-secs-counter-ruler-shows-1-m
Fix Min:Secs showing 1 ms early at exact millisecond positions
2026-10-19 02:54:20 -04:00
Brandon Thomas 3b3ccbd563 Merge pull request #577 from Silvertree2010/fix/h772-midi-notes-move-overflows-panics
Saturate midi.notes_move tick and pitch arithmetic
2026-10-19 02:54:16 -04:00
Brandon Thomas 2adae474b7 Merge pull request #567 from Silvertree2010/fix/431-audit-punch-recording-overwrites
Apply the punch range before placing a recorded take
2026-10-19 02:54:12 -04:00
Brandon ThomasandClaude Opus 5.5 5de30f96a1 docs: fix the #466 table rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:43:04 -04:00
Brandon ThomasandClaude Opus 5.5 d624f7ddb8 flac: checked offsets when reading the empty file's STREAMINFO
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:42:50 -04:00
Brandon ThomasandClaude Opus 5.5 f53051a35f copy_to_send: no send still means Send A, as the menu item calls it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:42:40 -04:00
Brandon Thomas ed5c7ba19b Merge pull request #755 from dylanmtaylor/docs/add-sonar-coderabbit-badges
docs: add SonarCloud and CodeRabbit badges to README
2026-10-19 02:31:37 -04:00
Brandon Thomas 437459f6eb Merge pull request #660 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-19 02:31:33 -04:00
Brandon Thomas 168b54e0fb Merge pull request #680 from marcoarnulfo/fix/vst3-param-units
VST3: show the plugin's own text for identity-mapped parameters
2026-10-19 02:31:29 -04:00
Brandon Thomas fdb8acd531 Merge pull request #606 from Silvertree2010/fix/435-audit-clap-preparation-silently
Reject CLAP port layouts beyond host limits instead of truncating
2026-10-19 02:31:25 -04:00
Brandon Thomas d92c8601a7 Merge pull request #583 from IanHollow/codex/artcraft-soundcraft-appstream-screenshot-07722a36
Add an editor screenshot to AppStream metadata
2026-10-19 02:31:19 -04:00
Brandon Thomas 07665e676a Merge pull request #530 from mikkelkonyher/open-on-insert
Open a plugin when it's inserted (#486)
2026-10-19 02:31:14 -04:00
Brandon Thomas 4b1ba55bfa Merge pull request #497 from euisuh/fix/mcp-batch-limit-460
fix(mcp): reject oversized batches before execution
2026-10-19 02:31:10 -04:00
Dylan M. Taylor 79e5bb6efb docs: put SonarCloud and CodeRabbit badges on their own line 2026-10-18 14:47:46 -04:00
Dylan M. Taylor 2e9ad2f421 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-10-18 12:31:49 -06:00
Derek Wright 4110f5ff10 Mix tests: never load real plug-ins
stored_state_comes_first_then_session_params ends with an offline render_range over a vst3: insert. That reached create_plugin and the real VST3 host, which scans the machine's plug-in folders and loads every installed plug-in in-process; some (Native Instruments) hang on unload, so the test binary never exited on a developer machine. CI has no plug-ins installed, so it stayed green.

Under cfg(test), create_plugin now returns None for third-party ids, so a test reaching the real hosts sees an absent plug-in instead of hanging. The rule, stated in the tests module doc: tests adopt fakes under clap:test.* ids and never call the real hosts.
2026-10-18 13:07:27 -04:00
NetterSilverandClaude Haiku 5.5 a72e46e7b6 Bounce the whole session when CLI --bounce has no range
Fixes #745

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:04:41 +02:00
Dylan M. Taylor 172dc4edc6 docs: add SonarCloud and CodeRabbit badges to README 2026-10-18 11:01:17 -04:00
Dylan M. Taylor 31f0389397 docs: drop references to competing commercial software (#743) 2026-10-18 10:39:50 -04:00
Brandon ThomasandClaude Opus 5.5 c1bf044a85 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:43:22 -04:00
reoniazx 516ef23703 fix(playback): fold both stereo channels into mono output 2026-10-18 00:42:19 +08:00
NetterSilverandClaude Haiku 5.5 6f3591ae11 Keep the existing volume lane in automation.clip_gain_to_volume
Found while testing crates/engine/src/cmd/mix.rs:676-681 (clip_gain_to_vol)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 13:43:37 +00:00
NetterSilverandClaude Haiku 5.5 c6037f3f7d Saturate note tick addition in event.extract_chords
Found while testing crates/engine/src/cmd/event_more.rs:579-580 (extract_chords)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 11:04:09 +02:00
NetterSilverandClaude Haiku 5.5 4a4e82daba Reject missing or non-numeric values in mix.pan and mix.trim
Found while testing crates/engine/src/cmd/mix.rs:237 (pan); same pattern at mix.rs:76 (mix.trim)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:54:08 +02:00
NetterSilverandClaude Haiku 5.5 0a9d29f227 Keep playlist sorted after delete_fades trims overlapping clips
Fixes #697

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:21:32 +00:00
NetterSilverandClaude Haiku 5.5 055ddbee7a Keep merging fader gestures once the undo history is full
Found while testing crates/engine/src/lib.rs:368-381 (Engine::execute_merged, with push_undo at 280-291)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 05:26:51 +02:00
NetterSilverandClaude Haiku 5.5 89d113a52a Reject unknown param in automation.clear instead of clearing all lanes
Found while testing crates/engine/src/cmd/mix.rs:516 (clear_lane)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 04:29:15 +02:00
Audiosyncrasy d65b04571c Merge branch 'main' into modify-group-dialog 2026-10-16 20:21:44 -04:00
NetterSilverandClaude Haiku 5.5 2a232036c2 Apply mix.volume once to each member of a targeted mix group
Found while testing crates/engine/src/cmd/mix.rs:206-222 (volume, with set_grouped at 185)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:11:34 +00:00
NetterSilverandClaude Haiku 5.5 4b1c025427 Honour start or end given alone in markers.add
Found while testing crates/engine/src/cmd/event.rs:300-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-16 18:08:32 +02:00
Marco Arnulfo 783ce8f44b VST3: show the plugin's own text for identity-mapped parameters (fixes #599) 2026-10-16 15:20:11 +02:00
NetterSilverandClaude Haiku 5.5 a4b6bd0179 Fix source offset when trimming the start of a stretched audio clip
Found while testing crates/model/src/clip.rs:246 (Clip::trim_start_to, used by crates/engine/src/edit.rs:36 split_clip)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 11:09:42 +00:00
reoniazx ef65ceafb8 fix(mix): exclude failed inserts from aux and master latency 2026-10-16 18:21:31 +08:00
reoniazx da404551b1 fix(mix): keep master automation within render blocks 2026-10-16 14:59:16 +08:00
Marco Arnulfo f2ae345f20 Clip > Remove Sync Point takes the sync point off the selected clips 2026-10-16 07:46:07 +02:00
reoniazx 3ea890496e fix(engine): preserve channels when splitting tracks into mono 2026-10-16 12:43:06 +08:00
euisuhandOpenAI Codex 7a086baa7b fix(ui): capture live plugin state before saving track presets
Track presets serialized the insert model's earlier capture instead of the
hosted plugin's current state. Reuse the existing save/bounce capture path
before preset serialization and exercise it with the real CLAP fixture.

Closes #661
Related #639

Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-16 13:36:19 +09:00
Dylan M. Taylor 18dd68eb1d ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:41:52 -04:00
Marco Arnulfo d484a716ad Tracks and Groups list rows publish their names to AccessKit (fixes #651) 2026-10-16 04:39:08 +02:00
Audiosyncrasy 6594badf37 Modify Group: edit a group's name, tracks and Edit/Mix linking
A track group's name, membership and Edit/Mix flags could only be set
when it was created: nothing in the engine or the UI could change them
afterwards, so a typo in a group name was permanent.

Add track.group_modify {group: id|name, name?, tracks?, edit?, mix?}.
Omitted fields keep their value. `tracks` replaces membership only when
it is an array, so a mistyped param cannot silently swap the members for
whatever happens to be selected; it dedupes, rejects an empty group, and
rejects an empty or duplicate name, since find_group resolves by name.
The group's letter is left alone: it is the stable id in the Groups list.

Dialog::Group gains an optional group id. None creates, as before; Some
pre-fills from the group and dispatches track.group_modify, so one dialog
serves both. Reached from Modify Group... in the Groups list context menu.
OK is now disabled while no track is checked, since both commands reject
an empty group and the old OK could only raise an error.

The command has no menu path, so it adds no menu leaf and the parity
checklist is unchanged.
2026-10-15 21:37:56 -04:00
Marco Arnulfo 5f03a5991e Plugin-only editor changes mark the session modified (fixes #455) 2026-10-16 03:20:53 +02:00
Marco Arnulfo 9477e7cb08 Contributors: add my name for the About window 2026-10-16 02:20:21 +02:00
Marco Arnulfo 29c014deef Ask before a session opened in the browser replaces a modified one (fixes #432)
Picking a session with Open Session or dropping one on the page in the
browser replaced the document as soon as its bytes were read, without the
Save Changes question the native Open goes through, so unsaved edits and
their undo history were lost. The bytes now wait behind that question,
asked when the read finishes: Don't Save opens them, Save saves first and
then opens them, Cancel drops them and leaves the document alone.
2026-10-16 02:01:15 +02:00
NetterSilverandClaude Haiku 5.5 0dfb074820 Shift controller events by Delay in track.write_midi_rtp
Found while testing crates/engine/src/cmd/track_more.rs:370-392 (write_rtp); playback side: crates/mix/src/lib.rs:1526-1527

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 23:07:39 +00:00
Paul GandClaude Opus 5.5 adab577f9a Resample through a polyphase bank: 5x faster rate-converting imports
Precompute the windowed-sinc weights once per phase of the reduced from/to ratio; each output is one dot product. Same weights, order and edges as the per-tap kernel; large ratios fall back to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:50:13 -07:00
Paul GandClaude Opus 5.5 05fe56ec1a Import audio off the UI thread (#597)
Decode, resample and build peaks on a worker; file.import_audio then only places the clip. CLI/MCP/control unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:21:28 -07:00
NetterSilverandClaude Haiku 5.5 eb4e2d21ba Make markers.edit apply its documented color parameter
Found while testing crates/engine/src/cmd/event.rs:338 (edit_marker; params doc at line 97)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 17:54:03 +02:00
MarekSpodBiedry fb087c879b Give the New Tracks name field its own row so it stays wide (#626) 2026-10-15 17:14:38 +02:00
NetterSilverandClaude Haiku 5.5 f887489603 Fix step_input extending a MIDI clip over the adjacent next clip
Found while testing crates/engine/src/cmd/event_more.rs:416-445

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:52:52 +02:00
NetterSilverandClaude Haiku 5.5 705c2b9077 Drop out-of-range tempo and meter events when sanitizing a session
Found while testing crates/time/src/tempo.rs:254

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:46:37 +00:00
NetterSilverandClaude Haiku 5.5 8840fbc87a Decode and probe zero-frame FLAC files written by our encoder
Found while testing crates/audio-io/src/symph.rs:41 (open/run, used by probe and decode for FLAC)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:46:27 +02:00
Marco Arnulfo 0a6e08a9c3 Drag automation breakpoints and Alt-click to delete them in the Edit window (fixes #601) 2026-10-15 11:34:16 +02:00
Billy Coleman III 075104f448 Apply strip mute automation within each processing block
Gate ordinary strip output and pre-fader copies at each represented sample
using the existing mute lane, mode, held values and threshold. Preserve the
fader and pan schedules, static suppression and hosted processing blocks.
2026-10-15 04:35:33 -04:00
NetterSilverandClaude Haiku 5.5 fff096876d Clamp retrospective record start_ticks to stay within MAX_POSITION
Found while testing crates/engine/src/cmd/event_more.rs:753

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:16:17 +02:00
NetterSilverandClaude Haiku 5.5 0224ca1e24 Reject CLAP port layouts beyond host limits instead of truncating
Fixes #435

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 04:07:58 +02:00
NetterSilverandClaude Haiku 5.5 ea0e4719fc Release held VST3 notes on reset instead of discarding them
Fixes #441

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 04:00:26 +02:00
NetterSilverandClaude Haiku 5.5 039b9f4f03 Reject invalid slot in mix.insert and mix.send
Found while testing crates/engine/src/cmd/mix.rs:300

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:50:59 +00:00
vxMacDev 2cba9ed4cd Save a session bundle only if it opens again (#433)
The bundle reader refuses more than 10,000 files or 1 GiB unpacked,
but the writer had no limits, so a browser save of a long session
could succeed, mark the session clean and produce a file SoundCraft
won't open. The writer now counts files and bytes the way the reader
does and refuses the save before anything is delivered or marked
clean. Both use one BundleLimits.
2026-10-14 16:54:56 -05:00
midasmatterhorn 315745b022 ui: MIDI Real-Time Properties submits only the property that changed (#444) 2026-10-15 00:36:03 +03:00
NetterSilverandClaude Haiku 5.5 c3d37f367d Fix Min:Secs showing 1 ms early at exact millisecond positions
Found while testing crates/time/src/format.rs:46

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 23:19:49 +02:00
NetterSilverandClaude Haiku 5.5 952ca5e4ed Round negative timecode and feet+frames positions symmetrically
Found while testing crates/time/src/timecode.rs:111

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 23:16:37 +02:00
midasmatterhorn 800b27013d session: reject duplicate ids and an exhausted id space on load (#448) 2026-10-14 23:22:31 +03:00
IanHollow 253707f65f Add an editor screenshot to AppStream metadata
Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com>
2026-10-14 13:15:03 -07:00
NetterSilverandClaude Haiku 5.5 8a5da53870 Reject invalid send slot in automation.copy_to_send
Found while testing crates/engine/src/cmd/mix.rs:697

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 19:03:00 +00:00
NetterSilverandClaude Haiku 5.5 6125f49ab1 Saturate midi.notes_move tick and pitch arithmetic
Found while testing crates/engine/src/cmd/midi.rs:129-130

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 18:54:16 +00:00
Diego LopesandClaude Sonnet 5.5 7bf80663dc Fix resampler edge read at device/session rate mismatch
The output resampler interpolated toward a stale sample at the end of every
mix block when the device rate differs from the session rate. It now holds
the last valid sample.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:59:47 -04:00
NetterSilverandClaude Haiku 5.5 6b4a7da010 Apply the punch range before placing a recorded take
Fixes #431

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 17:33:15 +00:00
Marco Arnulfo b50373d6db A Pencil click on a MIDI clip selects it, so the MIDI Editor follows (fixes #562) 2026-10-14 19:25:20 +02:00
Billy Coleman III 1e1458caf1 Compile exact dependency order for deep Aux routing
Prepare longest-path depths for acyclic Aux and Folder input routes so
reverse-created chains beyond eight returns preserve their audio. Count
output and send destinations once per producer, retain depth ties in
session order, and preserve the whole-graph bounded fallback for cycles.

Add public PCM regressions for deep chains, routing edits, callback sizes
and cycle compatibility, plus focused private ordering cases.
2026-10-14 13:14:38 -04:00
midasmatterhorn 76696826cd mix: clamp negative fractional source position in stretched-clip rendering 2026-10-14 18:01:42 +03:00
Hamza f8b2f8ce2e cli: reject unknown options and missing option values
`convert`, `run` and `script` read options with `arg_value`, which matches a
flag exactly and takes the argument after it. A misspelling, an `--option=value`
form or an option with no value therefore read as absent: the command ran
without what was asked for and still exited 0.

    convert IN OUT --rate=44100   wrote IN's 48000 Hz unchanged
    convert IN OUT --bitdepth 16  wrote the default 24-bit
    convert IN OUT --bogus        wrote the file, said nothing
    run --cmd session.new --bounce=OUT   bounced nothing

Check what each subcommand accepts and report an unknown option or a missing
value with a non-zero exit, and read `--option=value` as the value it looks
like. The documented space-separated form is unchanged.
2026-10-14 16:59:15 +05:00
Gert Pellin d4096db1f0 Bounce: a Dither option in the dialog and for stems (#522)
The Bounce dialog never sent `dither`, so every 16- and 24-bit bounce
was dithered, and file.bounce_stems hard-coded it. The dialog gets a
Dither (TPDF) checkbox, off by default and disabled for 32-bit float,
and file.bounce_stems takes the same `dither?: true` parameter as
file.bounce_mix.
2026-10-14 13:28:56 +02:00
Billy Coleman III 28c936a256 Prevent track-name collisions after truncation
Validate the trimmed 64-scalar stored track name before mutating the
document, return that exact name, and preserve no-op document state.

Retain routing-folder bus naming and repair with unique bus names,
stable routes and undo/redo coverage.
2026-10-13 20:42:34 -04:00
Saad Abdullah 750db81371 fix(audio-io): poison stream encoders after a failed write so later calls cannot claim success (#464) 2026-10-13 22:04:57 +00:00
Claude 79adfc0b57 playback: test the stream fallback order through first_started
Build and start each attempt in one helper, so the fallback order can be
tested without an audio device. The first config that builds and starts
wins; a build error or a refused start moves on to the next; none gives
None, which leads to the silent clock.

Claude-Session: https://claude.ai/code/session_014JH3FxAq7VHhaKjG9U3vfA
2026-10-13 18:30:29 +00:00
Claude 90318fd6d6 playback: retry the next stream config when a built stream won't start 2026-10-13 18:28:26 +00:00
Mikkel konyher 95b14c8df0 Open a plugin when it's inserted (#486)
Picking a plugin for an insert slot added it but opened nothing, so
every new plugin took an extra click on its slot. As in other DAWs, the
pick now opens it: a hosted plugin's own editor, else its plugin
window (built-in plugins, no audio engine, or an editor that can't open
here, such as CLAP plugins with only an embedded editor on macOS).

The insert menu's picks go through pick_insert, which remembers the
slot when mix.insert succeeds; SoundApp::open_inserted opens it on the
next logic pass, once the audio engine has the instance. Only the menu
opens it, so mix.insert from the CLI, MCP or control channel still
opens nothing. It covers empty and filled slots, in the Mix window and
the Edit window's Inserts column.

Tests: tests/open_on_insert.rs (empty slot, search and Enter,
replacing, Edit window column). menu_search closes the windows the
picks now open before it looks for the menu's Bypass.
2026-10-13 20:05:37 +02:00
Billy Coleman III 2dd8f7da55 Preserve exact channel formats when committing tracks
Retain the source ChannelFormat for committed audio tracks and reject
unsupported widths, overflowing ranges and incomplete renders before
replacing selected tracks. Preserve current routing and rollback behavior
and document the existing renderer limits with multichannel coverage.
2026-10-13 13:39:46 -04:00
Saad Abdullah 08b0704b0c fix(video): reject zero/invalid NAL length widths in length_prefixed_nals (#469) 2026-10-13 16:37:51 +00:00
MarekSpodBiedry d13f6657f8 Keep MIDI note selection valid across clip, session and note-order changes (#443) 2026-10-13 17:54:51 +02:00
MarekSpodBiedry bddc113f46 Heal keeps each clip's gain envelope and skips clips whose mute or stretch differ (#450) 2026-10-13 17:54:49 +02:00
MarekSpodBiedry 52dc4f9822 Reset clip effect EQ/compressor state on stop and seek (#442) 2026-10-13 17:53:21 +02:00
MarekSpodBiedry 33d59b26a6 Keep controller-only tracks when importing MIDI (#454) 2026-10-13 17:53:03 +02:00
MarekSpodBiedry b7329ee904 Keep unrelated overlapping clips untouched when deleting fades (#449) 2026-10-13 17:52:39 +02:00
MarekSpodBiedry 9a398d79ff Load older sessions whose track height is the bare Fit string (#447) 2026-10-13 17:52:29 +02:00
MarekSpodBiedry 9afb266fce Fix bundle opening losing audio for sources that share one file (#446) 2026-10-13 17:52:13 +02:00
MarekSpodBiedry df0e8928d7 Keep overlapping clips of a clip group when importing it (#452) 2026-10-13 17:52:12 +02:00
MarekSpodBiedry 75eb152f03 Create session backups exclusively and stop when backup numbers run out (#426) 2026-10-13 17:52:12 +02:00
MarekSpodBiedry 2a43710e52 Scale fades, sync point and gain envelope when importing a clip group at another sample rate (#453) 2026-10-13 17:51:48 +02:00
MarekSpodBiedry 6c84416c3c Reject invalid numeric prereleases and restore Cargo.toml when version update fails (#471) 2026-10-13 17:51:48 +02:00
MarekSpodBiedry 807230167d Reset clip stretch after AudioSuite bakes it into the new source (#451) 2026-10-13 17:51:40 +02:00
MarekSpodBiedry f9d04ffc98 Validate the full WAVE_FORMAT_EXTENSIBLE fmt chunk (#465) 2026-10-13 17:50:18 +02:00
MarekSpodBiedry 7bcb4e4802 Do not retry remote-control commands that may already have run (#458) 2026-10-13 17:49:50 +02:00
MarekSpodBiedry 9209909ff7 Count the partial quartet in the base64 decoded-size cap (#468) 2026-10-13 17:49:23 +02:00
MarekSpodBiedry edd2b0ef73 Keep audio-io stream test limits as u64 so it compiles on 32-bit targets (#462) 2026-10-13 17:48:22 +02:00
MarekSpodBiedry 8ac95607b7 Return TooLarge when the WAV header size overflows u64 (#467) 2026-10-13 17:48:14 +02:00
NetterSilver 7b22753330 Docs: update the progress docs for this change 2026-10-13 17:25:22 +02:00
NetterSilver d8ab50b5fc Sampler: a ping-pong loop keeps its phase for steps wider than the loop (#463)
The ping-pong loop reflected a voice at the loop's end once and clamped it to the
edge, so a step wider than the loop (a narrow loop with high transposition or
sample-rate conversion) dropped the rest of the travel and made the pitch and loop
phase wrong. The distance past the edge is now folded into the loop: an even number
of crossings keeps the direction, an odd number turns it round. Steps narrower than
the loop move exactly as before.
2026-10-13 17:25:22 +02:00
euisuhandOpenAI Codex 8d252c8b2e fix(mcp): M8 reject oversized batches before execution
Reject oversized batches before any side effects instead of reporting success after truncating calls.

Closes #460

Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-14 00:15:26 +09:00
NetterSilver 686f8c9692 Docs: update the progress docs for this change 2026-10-13 17:11:28 +02:00
NetterSilver 7f2ff83c44 WAV: an empty data chunk followed by metadata has no frames (#466)
A data chunk of size 0 was always read as "everything that follows", so a finished
empty WAV with a LIST or bext chunk after its data decoded the metadata as samples
and reported a longer file. The streaming-writer case is kept: a zero size is read
as empty only when the RIFF size is the file's and only whole chunks follow; a
recording whose sizes were never finalised still gives the audio that was written.
2026-10-13 17:11:28 +02:00
Royce 0c19777c8a fix(ui): add Fedora font paths so DejaVu and Liberation load on Fedora (#45)
The Linux font search list in `crates/ui-egui/src/fonts.rs` covered
Debian/Ubuntu (`/usr/share/fonts/truetype/dejavu`, `/usr/share/fonts/TTF`,
`/usr/share/fonts/dejavu`) and Arch (`/usr/local/share/fonts/dejavu`)
for DejaVu, and a single path for Liberation Sans. It did not cover
Fedora's package layout:

- `/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf` (and -Bold)
- `/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf`

On Fedora, neither path matched, the `load_from_fontconfig` path returned
None (e.g. the user only has Liberation Sans installed, not DejaVu), and
the UI fell back to egui's built-in fonts. Those don't cover the menu
glyphs (`→ ▸ ▾ ♯ ✕`), so the symbols showed as empty boxes.

Add the two Fedora paths to the REGULAR and BOLD candidates, in the same
order as the existing Debian and Arch paths. `load` still tries
fontconfig first; the direct paths are the fallback.

cargo test -p soundcraft-ui-egui: 105 passed, 0 failed.
2026-10-13 22:50:49 +08:00
Jafz2001 bf3cd7ac09 fix(routing): remove folder bus on delete 2026-10-13 11:40:34 -03:00
Limestudio90andClaude Opus 5.5 245015fd89 VST3 host: IMessage and IAttributeList for plugins (Helix loads its database)
`IHostApplication::createInstance` returned nothing, so plugins whose
controller and processor exchange `IMessage`s through
`IConnectionPoint::notify` could not create one. Line 6 Helix Stadium
Native reported "Failed to get content database recovery info (Out of
memory condition. [code -2])", loaded no models and exposed no
parameters.

The host now creates `IMessage` (an id plus its attribute list) and
`IAttributeList` (ints, floats, UTF-16 strings and binary blobs, by
NUL-terminated attribute id). Strings and blobs are kept in their own
allocation so pointers from `getBinary` stay valid while the list holds
the value; strings are capped at 1M UTF-16 units and blobs at 256 MiB.
Null arguments are refused rather than read. Other classes are still not
provided.

Verified with Helix Stadium Native 1.03.3a on Windows: the error is gone,
its model database and presets load, and its parameters appear in the
generic plugin window. A unit test drives the objects through the COM
interfaces the way a plugin does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:55:34 +02:00
Justin JohnandClaude Opus 5.5 1bebc52bf4 MIDI writer: clamp out-of-range pitches and controller numbers instead of wrapping (#472)
Pitch, release velocity, CC number and poly-pressure pitch were masked with
0x7F, so 128 became 0 and 255 became 127. They now clamp to 127, as the
write_smf doc promises and note-on velocity already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:13:40 +05:30
Justin JohnandClaude Opus 5.5 194cf8c83d Portable mode: portable.txt beside the executable keeps settings in SoundCraftData (#268)
With the marker present, ui.json, Autosave, Presets and logs go to
<exe dir>/SoundCraftData instead of the user profile. SOUNDCRAFT_CONFIG_DIR
still wins, and a data folder that can't be written falls back to the
profile with a warning in the log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:04:26 +05:30
Justin JohnandClaude Opus 5.5 606ddac169 Track › Instrument: choose the selected track's instrument from the menu bar (#374)
The submenu under Track › New... opens the same picker as the Mix window's
instrument slot for the first selected instrument track, and is greyed out
with a hint when none is selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:44:34 +05:30
webdevtodayjason e10b245652 Keep mute changes in place when thinning automation 2026-10-13 06:58:16 -05:00
299 changed files with 14791 additions and 6673 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+38
View File
@@ -0,0 +1,38 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
# craft-ui is on egui 0.36 until storytold/craft-libs#199 lands.
ignore:
- dependency-name: "egui"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "eframe"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "egui_kittest"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+27
View File
@@ -0,0 +1,27 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
libgl1-mesa-dev libasound2-dev
+5 -6
View File
@@ -3,20 +3,19 @@
# player falls back to its silent clock when the VM has no sound device.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
schedule:
- cron: "47 3 * * *"
push:
branches: [release]
pull_request:
paths:
- "crates/playback/**"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "47 3 * * *"
workflow_dispatch:
concurrency:
+37 -9
View File
@@ -18,16 +18,44 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
nix:
# 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/nix.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
- name: Git dependencies come from storytold
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
+3 -3
View File
@@ -10,15 +10,15 @@ on:
type: string
default: ""
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
# The shared workflow creates the draft release, which needs write access to contents.
permissions:
contents: 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:
+21
View File
@@ -0,0 +1,21 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+1
View File
@@ -32,6 +32,7 @@ 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`)
Generated
+267 -320
View File
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.8.0"
version = "0.13.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -32,21 +32,27 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
proptest = "1"
# audio
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
rustfft = "6"
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
zip = { version = "9", default-features = false }
cpal = "0.16"
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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] }
# egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands.
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
@@ -64,9 +70,11 @@ type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
+15 -12
View File
@@ -7,10 +7,14 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.soundcraft.png" width="128" height="128" alt="SoundCraft icon: a singing bird on a blue tile">
</p>
<h1 align="center">SoundCraft</h1>
<p align="center">
<b>Recording, editing and mixing audio; an open-source, clean-room reimplementation of Avid Pro Tools, rebuilt in pure Rust.</b>
<b>Recording, editing and mixing audio; an open-source, modern digital audio workstation (DAW), built in pure Rust.</b>
</p>
<p align="center">
@@ -25,6 +29,9 @@
<img alt="Platforms" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-0b7385?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
<img alt="Status: pre-alpha" src="https://img.shields.io/badge/status-pre--alpha-orange?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_soundcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/soundcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -115,11 +122,11 @@ where things are.
- **Automation:** volume, pan, mute, send and every plugin parameter; Write, Touch, Latch and
Trim passes recorded live from the faders; thin, glide, coalesce and convert to clip gain.
- **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer,
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi,
rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither,
a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the
keyboard), all original. **CLAP plugins** load too. Any processor
runs offline as AudioSuite, and user presets save per plugin.
channel strip, room and plate reverbs, mod delay, auto pan, chorus, flanger, phaser, ring
modulator, saturator, lo-fi, rectifier, subharmonic, pitch shifter, time shift, gain, trim,
invert, DC removal, signal generator, dither, a subtractive synth, a drum synth and a sampler
(plays any WAV, AIFF, FLAC, MP3 or OGG across the keyboard), all original. **CLAP plugins**
load too. Any processor runs offline as AudioSuite, and user presets save per plugin.
- **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event
list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files,
and Audio-to-MIDI from a sung or played melody.
@@ -131,7 +138,7 @@ where things are.
pitch-preserving Elastic warping.
- **Audio files:** WAV/BWF/RF64, AIFF/AIFC and FLAC in and out; MP3, Ogg Vorbis, AAC, ALAC, CAF
and the audio of MP4/MOV movies in. Any sample rate and bit depth.
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with dither and
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with optional dither and
normalisation, reporting peak, true peak and integrated loudness (LUFS).
- **Fast:** a parallel mix engine, copy-on-write undo, and a UI that holds 120 frames per second
with seventy-odd tracks playing.
@@ -375,13 +382,9 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and SoundCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Avid Technology, Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float Panel, Move Panel to Group, and Close Panel; the tab group's menu adds Float Tab Group and Close Tab Group. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
+37 -1
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-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness by audience with hours to ~95 %) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** trivial (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,25 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **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.
- **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change.
- **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change.
- **2026-10-24:** The Strip Silence window has Extract and Separate buttons. Extract removes the audio and keeps the silent parts of the selection, and Separate separates the clips at the edges of the silence without removing anything. Minimum Strip Duration goes up to 4,000 ms. No readiness percentage change.
- **2026-10-24:** Clip › Loop… (Cmd+Alt+L) opens the Clip Looping dialog: Number of Loops, Loop Length in the main counter's time format, or Loop Until End of Session or Next Clip. A loop length that is not a whole number of loops cuts the last loop short, and Unloop removes that short loop too. No readiness percentage change.
- **2026-10-24:** AudioSuite ▸ Pitch Shift ▸ Vari-Fi slows the selected clips down to a complete stop or speeds them up from one, and the pitch follows the speed. Fit To keeps each clip's length and plays two-thirds of it; Extend plays all of it and makes the clip 150 % long. Fades On fades the audio out with the slowdown or in with the speedup. Before, the menu item resampled the clip at a constant speed. No readiness percentage change.
- **2026-10-24:** A built-in Auto Pan plugin pans the mono sum of a stereo track between left and right. An LFO with seven waveforms moves it at a rate in BPM or at the session tempo, running free, once, in step with the transport, or when the level crosses a threshold or a manual trigger changes; an envelope follower can move it by level instead. Width, Manual and Path set the field, and Output sets the level. No readiness percentage change.
- **2026-10-24:** A built-in Subharmonic (`subharmonic`) adds the octave below a lower and an upper band of the input, chosen from four frequency ranges, with a direct bass band, a gain and a solo for each band, Drive with six characters, HP and LP filters, and a Mix that keeps one path at full level. No readiness percentage change.
- **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change.
- **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change.
- **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change.
- **2026-10-16:** Master fader volume automation uses each render block's actual last sample, matching track faders. Breakpoints in the next block no longer pull the preceding block's level toward them, and ramps reach their last-sample value. Regression tests cover nonzero starts, one-sample callbacks and several block sizes. No readiness percentage change.
- **2026-10-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change.
- **2026-10-16:** Split into Mono extracts the correct source channel for each mono track instead of playing channel zero on every track. Clip offsets, gain, fades and stretch, source timestamps, Undo/Redo and saved audio are preserved; repeated clips share each extracted mono source. Unavailable media returns an error without partial edits. No readiness percentage change.
- **2026-10-14:** MCP `batch` rejects more than 1000 calls before executing any command, instead of silently running only the first 1000 (#460). No readiness percentage change.
- **2026-10-14:** `soundcraft-cli convert` dithers only when the output loses resolution (fewer bits than the source, a float or lossy source, or resampled/normalized samples); a same-depth, wider or 32-bit float conversion copies the samples exactly and digital silence stays silent. `--dither` and `--no-dither` override the choice (#548)
- **2026-10-13:** WAV: an empty data chunk followed by metadata (LIST, bext) reads as no audio; a recording that was never finalised (sizes left at 0) still gives what was written (#466)
- **2026-10-13:** Sampler: a ping-pong loop keeps its phase and direction when a step is wider than the loop (a narrow loop with high transposition), instead of reflecting once and dropping the rest of the travel (#463)
- **2026-10-13 (VST3 host messages):** The VST3 host creates `IMessage` and `IAttributeList` objects for plugins (`IHostApplication::createInstance`), so plugins whose controller and processor talk through `IConnectionPoint::notify` work: Line 6 Helix Stadium Native now loads its content database (it reported "Failed to get content database recovery info (Out of memory condition)") and exposes its parameters. No readiness percentage change.
- **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265)
- **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246)
- **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351)
@@ -155,6 +174,23 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence Extract and Separate; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Clip Looping dialog; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: AudioSuite Vari-Fi speed ramps; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Auto Pan plugin; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Subharmonic plugin; readiness estimates unchanged |
| 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged |
| 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged |
| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
| 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged |
| 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged |
| 2026-10-16 | trivial | Split into Mono preserves each source channel, clip edits and saved audio; readiness estimates unchanged |
| 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) |
| 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) |
| 2026-10-13 | trivial | Progress log: sampler ping-pong phase for wide steps (#463) |
| 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) |
| 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) |
| 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) |
+25 -31
View File
@@ -3,30 +3,22 @@
use std::path::PathBuf;
#[derive(Default)]
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
pub read_roots: Vec<PathBuf>,
pub write_roots: Vec<PathBuf>,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
/// Returns whether this argument is a root flag. Each flag may be given more than once (#874),
/// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
let roots = match flag {
"--automation-read-root" => &mut self.read_roots,
"--automation-write-root" => &mut self.write_roots,
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
@@ -40,8 +32,7 @@ impl AutomationArgs {
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
roots.push(value.into());
Ok(true)
}
}
@@ -51,40 +42,43 @@ mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
fn roots_add_up_and_other_arguments_are_left() {
let mut roots = AutomationArgs::default();
let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--demo", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
assert_eq!(args.next().as_deref(), Some("song.scraft"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
// #874: a flag given again adds a folder, in either spelling.
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut roots = AutomationArgs { read_roots: vec!["read".into()], write_roots: vec!["write".into()] };
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
}
let mut roots = AutomationArgs::new(None, None);
let mut roots = AutomationArgs::default();
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
let mut roots = AutomationArgs::default();
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
}
}
+174 -32
View File
@@ -2,18 +2,22 @@
//!
//! ```text
//! soundcraft-cli info FILE audio file or .scraft summary
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize]
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] [--dither | --no-dither]
//! dithers only when the output loses resolution, unless told
//! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect]
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT] (one `ID JSON` per line)
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT [--start T --end T]] (one `ID JSON` per line;
//! without --start/--end, --bounce covers the whole session)
//! soundcraft-cli commands [FILTER] list commands
//! soundcraft-cli describe ID one command's details
//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR] [--automation-write-root DIR]
//! MCP server on stdio. The two roots confine every file the
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR]... [--automation-write-root DIR]...
//! MCP server on stdio. The roots confine every file the
//! agent's commands read (open, import, presets) and write
//! (save, bounce, export) to those folders, links followed;
//! a root left out grants none of its access. They run the
//! in-process server, so they conflict with --connect.
//! each flag may be given more than once, a path inside any
//! of its folders being allowed, and a kind left out grants
//! none of its access. They run the in-process server, so
//! they conflict with --connect.
//! soundcraft-cli parity [--write PATH] parity report (markdown)
//! soundcraft-cli plugins list built-in plugins
//! ```
@@ -38,9 +42,57 @@ macro_rules! outln {
}
fn arg_value(args: &[String], flag: &str) -> Option<String> {
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned()
args.iter().enumerate().find_map(|(i, a)| {
if a == flag { args.get(i + 1).cloned() } else { a.strip_prefix(flag).and_then(|rest| rest.strip_prefix('=')).map(str::to_owned) }
})
}
/// Every option a subcommand accepts, with whether it carries a value.
type Options = [(&'static str, bool)];
/// A misspelled option, or one whose value is missing, reads as absent through `arg_value`, so the
/// subcommand would run without what was asked for and still report success. Reject both instead.
fn check_options(args: &[String], allowed: &Options) -> Result<(), String> {
let mut i = 0;
while i < args.len() {
let Some(name) = args[i].strip_prefix("--") else {
i += 1;
continue;
};
let (name, inline) = name.split_once('=').map_or((name, None), |(n, v)| (n, Some(v)));
let flag = format!("--{name}");
let Some(&(_, takes_value)) = allowed.iter().find(|(f, _)| f == &flag) else {
return Err(format!("unknown option {flag}"));
};
match (takes_value, inline) {
(false, Some(_)) => return Err(format!("{flag} takes no value")),
(true, None) => {
// A following option is the next flag, not this one's value.
if args.get(i + 1).is_none_or(|v| v.starts_with("--")) {
return Err(format!("{flag} needs a value"));
}
i += 1;
}
_ => {}
}
i += 1;
}
Ok(())
}
/// `--in`, `--demo`/`--sample`, `--bounce`, `--start`, `--end`, `--save` and `--inspect`, read by
/// `load_engine` and `finish` and so shared by `run` and `script`.
const ENGINE_OPTIONS: &Options = &[
("--in", true),
("--demo", false),
("--sample", false),
("--bounce", true),
("--start", true),
("--end", true),
("--save", true),
("--inspect", false),
];
fn fail(msg: impl std::fmt::Display) -> ExitCode {
eprintln!("soundcraft-cli: {msg}");
ExitCode::FAILURE
@@ -110,7 +162,7 @@ fn info(args: &[String]) -> ExitCode {
Ok(b) => b,
Err(e) => return fail(format!("{path}: {e}")),
};
let ext = std::path::Path::new(path).extension().and_then(|x| x.to_str());
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str);
match soundcraft_audio_io::probe(&bytes, ext) {
Ok(i) => {
outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default());
@@ -122,6 +174,10 @@ fn info(args: &[String]) -> ExitCode {
fn convert(args: &[String]) -> ExitCode {
let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") };
const OPTIONS: &Options = &[("--rate", true), ("--bit-depth", true), ("--normalize", false), ("--dither", false), ("--no-dither", false)];
if let Err(e) = check_options(&args[2..], OPTIONS) {
return fail(e);
}
let rate = match parse_rate(arg_value(args, "--rate").as_deref()) {
Ok(r) => r,
Err(e) => return fail(e),
@@ -130,12 +186,16 @@ fn convert(args: &[String]) -> ExitCode {
Ok(b) => b,
Err(e) => return fail(e),
};
let dither_flag = match parse_dither(args) {
Ok(d) => d,
Err(e) => return fail(e),
};
let bytes = match std::fs::read(input) {
Ok(b) => b,
Err(e) => return fail(format!("{input}: {e}")),
};
let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str());
let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str);
let (info, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
Ok(x) => x,
Err(e) => return fail(format!("{input}: {e}")),
};
@@ -143,14 +203,17 @@ fn convert(args: &[String]) -> ExitCode {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
buf.sample_rate = rate;
}
if args.iter().any(|a| a == "--normalize") {
let normalize = args.iter().any(|a| a == "--normalize");
if normalize {
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
}
let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
let processed = rate.is_some_and(|r| r != info.sample_rate) || normalize;
let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed));
let out_ext = std::path::Path::new(output).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
};
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None };
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) {
Ok(()) => {
outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs());
@@ -179,6 +242,41 @@ fn parse_bit_depth(v: Option<&str>) -> Result<soundcraft_audio_io::BitDepth, Str
}
}
/// `--dither` / `--no-dither`: `Some` when the caller chose, `None` to decide from the depths.
fn parse_dither(args: &[String]) -> Result<Option<bool>, String> {
match (args.iter().any(|a| a == "--dither"), args.iter().any(|a| a == "--no-dither")) {
(true, true) => Err("--dither and --no-dither: choose one".into()),
(true, false) => Ok(Some(true)),
(false, true) => Ok(Some(false)),
(false, false) => Ok(None),
}
}
/// Whether `convert` dithers when not told: only when writing integer samples that cannot hold the
/// source exactly, i.e. the output has fewer bits than the source, the source is float or lossy, or
/// resampling/normalizing produced new sample values. A 16-bit file written as 16 or 24-bit, or
/// anything written as 32-bit float, is copied sample for sample (digital silence stays silent).
fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_audio_io::BitDepth, processed: bool) -> bool {
use soundcraft_audio_io::{BitDepth, SampleFormat};
let target_bits = match target {
BitDepth::Float32 => return false,
BitDepth::Int16 => 16,
BitDepth::Int24 => 24,
BitDepth::Int32 => 32,
};
if processed {
return true;
}
match source {
SampleFormat::Int8 => target_bits < 8,
SampleFormat::Int16 => target_bits < 16,
SampleFormat::Int24 => target_bits < 24,
// Decoded through f32, which holds 24 bits: a 32-bit integer source has already lost
// resolution, so treat it like float.
SampleFormat::Int32 | SampleFormat::Float32 | SampleFormat::Float64 | SampleFormat::Compressed => true,
}
}
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
fn missing_warnings(missing: &[String]) -> Vec<String> {
missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
@@ -212,6 +310,11 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
if let Some(s) = arg_value(args, "--end") {
p["end"] = time_arg(&s);
}
// No range given: bounce the whole session, not whatever edit selection the commands left.
if p.get("start").is_none() && p.get("end").is_none() {
p["start"] = json!(0);
p["end"] = json!(e.session().content_end().max(1));
}
match e.execute("file.bounce_mix", &p) {
Ok(v) => outln!("{v}"),
Err(err) => return fail(err),
@@ -243,32 +346,42 @@ fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> {
}
fn run(args: &[String]) -> ExitCode {
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--cmd", true)]).collect();
if let Err(e) = check_options(args, &options) {
return fail(e);
}
let mut e = match load_engine(args) {
Ok(e) => e,
Err(err) => return fail(err),
};
let mut i = 0;
while i < args.len() {
if args.get(i).is_some_and(|a| a == "--cmd")
&& let Some(spec) = args.get(i + 1)
{
let (id, params) = match parse_cmd_spec(spec) {
Ok(v) => v,
Err(err) => return fail(err),
};
match e.execute(&id, &params) {
Ok(v) => outln!("{id}: {v}"),
Err(err) => return fail(format!("{id}: {err}")),
}
i += 1;
let mut it = args.iter();
while let Some(a) = it.next() {
let spec = match a.strip_prefix("--cmd=") {
Some(inline) => inline,
None if a == "--cmd" => match it.next() {
Some(next) => next,
None => continue,
},
None => continue,
};
let (id, params) = match parse_cmd_spec(spec) {
Ok(v) => v,
Err(err) => return fail(err),
};
match e.execute(&id, &params) {
Ok(v) => outln!("{id}: {v}"),
Err(err) => return fail(format!("{id}: {err}")),
}
i += 1;
}
finish(&mut e, args)
}
fn script(args: &[String]) -> ExitCode {
let Some(path) = args.first() else { return fail("script FILE") };
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--keep-going", false)]).collect();
if let Err(e) = check_options(&args[1..], &options) {
return fail(e);
}
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => return fail(format!("{path}: {e}")),
@@ -398,7 +511,7 @@ struct McpArgs {
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
let mut connect = None;
let mut demo = false;
let mut roots_args = automation_args::AutomationArgs::new(None, None);
let mut roots_args = automation_args::AutomationArgs::default();
let mut it = args.iter().cloned();
while let Some(a) = it.next() {
if roots_args.consume(&a, &mut it)? {
@@ -411,7 +524,9 @@ fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
_ => {}
}
}
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
let read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect();
let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect();
let roots = AutomationRoots::new(&read, &write)?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
so drop --connect (or use --connect without the roots)"
@@ -437,8 +552,7 @@ fn mcp(args: &[String]) -> ExitCode {
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("soundcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
eprintln!("soundcraft-cli: files confined: {}", roots.describe());
h = h.with_automation_roots(Some(roots));
}
let mut s = Server::new(h);
@@ -511,6 +625,34 @@ mod tests {
assert!(parse_bit_depth(Some("8")).is_err());
}
#[test]
fn convert_dither_flags() {
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(parse_dither(&args(&["in.wav", "out.wav"])), Ok(None));
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--dither"])), Ok(Some(true)));
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--no-dither"])), Ok(Some(false)));
assert!(parse_dither(&args(&["in.wav", "out.wav", "--dither", "--no-dither"])).is_err());
}
#[test]
fn convert_dithers_only_when_resolution_is_lost() {
use soundcraft_audio_io::{BitDepth, SampleFormat};
// Same depth or wider: the samples fit exactly.
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int16, false));
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int24, false));
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Int24, false));
assert!(!auto_dither(SampleFormat::Int8, BitDepth::Int16, false));
// Float output never dithers.
assert!(!auto_dither(SampleFormat::Float32, BitDepth::Float32, true));
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Float32, false));
// Fewer bits, a float or lossy source, or processed samples: dither.
assert!(auto_dither(SampleFormat::Int24, BitDepth::Int16, false));
assert!(auto_dither(SampleFormat::Float32, BitDepth::Int24, false));
assert!(auto_dither(SampleFormat::Compressed, BitDepth::Int16, false));
assert!(auto_dither(SampleFormat::Int32, BitDepth::Int24, false));
assert!(auto_dither(SampleFormat::Int16, BitDepth::Int16, true));
}
#[test]
fn missing_media_gets_one_warning_each() {
assert!(missing_warnings(&[]).is_empty());
+95
View File
@@ -0,0 +1,95 @@
//! `soundcraft-cli convert` dithers only when the output loses resolution (#548), run as the real binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use std::process::Command;
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A 16-bit mono WAV of `samples`, written without dither so the file holds exactly these values.
fn write_wav16(path: &Path, samples: &[i16]) {
let mut buf = AudioBuffer::new(48_000, 1, samples.len());
for (d, s) in buf.channels[0].iter_mut().zip(samples) {
*d = f32::from(*s) / 32768.0;
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
}
fn convert(input: &Path, output: &Path, extra: &[&str]) {
let o = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"))
.env("SOUNDCRAFT_NO_PREFS", "1")
.arg("convert")
.arg(input)
.arg(output)
.args(extra)
.output()
.unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
}
/// The decoded samples of `path`, scaled to 16-bit steps.
fn steps16(path: &Path) -> Vec<f64> {
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
buf.channels[0].iter().map(|s| f64::from(*s) * 32768.0).collect()
}
#[test]
fn convert_copies_samples_when_nothing_is_lost() {
let dir = tmpdir("exact");
let (silence, tone) = (dir.join("silence.wav"), dir.join("tone.wav"));
let tone_samples: Vec<i16> = (0..4800).map(|i| (8000.0 * (f64::from(i) * 0.05).sin()) as i16).collect();
write_wav16(&silence, &[0; 4800]);
write_wav16(&tone, &tone_samples);
let want: Vec<f64> = tone_samples.iter().map(|s| f64::from(*s)).collect();
// Same depth, wider and float: every sample comes back unchanged, silence stays silent.
for (depth, name) in [("16", "s16.wav"), ("24", "s24.wav"), ("32f", "s32.wav")] {
let out = dir.join(name);
convert(&silence, &out, &["--bit-depth", depth]);
assert!(steps16(&out).iter().all(|s| *s == 0.0), "silence at {depth} is not silent");
let out = dir.join(format!("t{name}"));
convert(&tone, &out, &["--bit-depth", depth]);
assert_eq!(steps16(&out), want, "tone at {depth} changed");
}
// The default output depth (24-bit) too.
let out = dir.join("default.wav");
convert(&tone, &out, &[]);
assert_eq!(steps16(&out), want);
// Asked for, dither is still applied.
let out = dir.join("forced.wav");
convert(&silence, &out, &["--bit-depth", "16", "--dither"]);
assert!(steps16(&out).iter().any(|s| *s != 0.0), "--dither did not dither");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn convert_dithers_when_reducing_depth_unless_told_not_to() {
let dir = tmpdir("reduce");
let src = dir.join("src24.wav");
// A 24-bit file with detail below one 16-bit step.
let mut buf = AudioBuffer::new(48_000, 1, 4800);
for (i, s) in buf.channels[0].iter_mut().enumerate() {
*s = 0.3 / 32768.0 * (i as f32 * 0.01).sin();
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None };
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let dithered = dir.join("d16.wav");
convert(&src, &dithered, &["--bit-depth", "16"]);
assert!(steps16(&dithered).iter().any(|s| *s != 0.0), "24 to 16-bit was not dithered");
// Plain rounding: everything is below half a step, so it all rounds to zero.
let plain = dir.join("p16.wav");
convert(&src, &plain, &["--bit-depth", "16", "--no-dither"]);
assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1,9 +1,8 @@
//! `soundcraft-cli mcp` with the automation root flags, run as the real binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use serde_json::Value;
@@ -25,10 +24,33 @@ fn tmpdir(tag: &str) -> PathBuf {
dir
}
/// `path` made absolute with links followed (`/var` becomes `/private/var` on macOS), as the server
/// prints a root: without the `\\?\` prefix that `canonicalize` adds on Windows.
fn resolved(path: &Path) -> PathBuf {
let path = std::fs::canonicalize(path).unwrap();
if !cfg!(windows) {
return path;
}
let text = path.to_str().unwrap();
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
text.strip_prefix(r"\\?\").map_or(path.clone(), PathBuf::from)
}
/// Send `calls` (tool name, arguments) to `soundcraft-cli mcp <args>`; returns whether it exited
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
mcp_session_in(None, args, calls)
}
/// [`mcp_session`] with the server started in the working directory `cwd`.
fn mcp_session_in(cwd: Option<&Path>, args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
let mut cmd = bin();
if let Some(cwd) = cwd {
cmd.current_dir(cwd);
}
let mut child = cmd.arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
@@ -102,6 +124,73 @@ fn mcp_automation_roots_confine_the_server() {
let _ = std::fs::remove_dir_all(base);
}
/// #874: each root flag may be given more than once. A path inside any root of its kind passes;
/// the two roots' common parent, `..` out of a root and a link out of one stay refused.
#[test]
fn mcp_automation_roots_take_several_folders() {
let base = resolved(&tmpdir("several"));
for d in ["in", "in2", "out", "out2", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
for f in ["in/a.wav", "in2/b.wav", "outside/secret.wav", "parent.wav"] {
make_wav(&base.join(f));
}
let p = |f: &str| base.join(f).to_str().unwrap().to_string();
let sep = std::path::MAIN_SEPARATOR;
let calls = [
("import_audio", serde_json::json!({"path": p("in/a.wav")})),
("import_audio", serde_json::json!({"path": p("in2/b.wav")})),
// The roots' common parent is not a root.
("import_audio", serde_json::json!({"path": p("parent.wav")})),
("import_audio", serde_json::json!({"path": format!("{}{sep}..{sep}outside{sep}secret.wav", p("in2"))})),
("bounce_mix", serde_json::json!({"path": p("out/a.wav")})),
("bounce_mix", serde_json::json!({"path": p("out2/b.wav")})),
("bounce_mix", serde_json::json!({"path": p("x.wav")})),
// Read and write stay separate: a read root is not a write root, nor the other way round.
("bounce_mix", serde_json::json!({"path": p("in2/c.wav")})),
("import_audio", serde_json::json!({"path": p("out2/b.wav")})),
];
let read2 = format!("--automation-read-root={}", p("in2"));
let args = ["--automation-read-root", &p("in"), &read2, "--automation-write-root", &p("out"), "--automation-write-root", &p("out2")];
let (success, stderr, replies) = mcp_session(&args, &calls);
assert!(success, "{stderr}");
assert!(stderr.contains(&format!("read {}, {}", p("in"), p("in2"))), "{stderr}");
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, [false, false, true, true, false, false, true, true, true], "{replies:?}");
for (i, kind) in [(2, "read"), (3, "read"), (6, "write"), (7, "write"), (8, "read")] {
assert!(text(&replies[i]).contains(&format!("outside the {kind} roots ")), "{}", text(&replies[i]));
}
assert!(base.join("out/a.wav").is_file() && base.join("out2/b.wav").is_file());
assert!(!base.join("x.wav").exists() && !base.join("in2/c.wav").exists());
// A link in one root that leads out of all of them is outside.
#[cfg(unix)]
{
std::os::unix::fs::symlink(base.join("outside/secret.wav"), base.join("in2/leak.wav")).unwrap();
let (_, _, replies) = mcp_session(&args, &[("import_audio", serde_json::json!({"path": p("in2/leak.wav")}))]);
assert!(text(&replies[0]).contains("outside the read roots"), "{}", text(&replies[0]));
}
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_bare_save_session_name_lands_in_the_working_directory_inside_the_roots() {
let work = tmpdir("bare-name");
let work_s = work.to_str().unwrap();
let calls = [
("new_session", serde_json::json!({"name": "t", "sample_rate": 48000})),
("execute", serde_json::json!({"command": "track.new", "params": {"kind": "audio", "name": "Tone", "format": "mono"}})),
("save_session", serde_json::json!({"path": "t.scraft"})),
];
let (success, stderr, replies) = mcp_session_in(Some(&work), &["--automation-read-root", work_s, "--automation-write-root", work_s], &calls);
assert!(success, "{stderr}");
for reply in &replies {
assert_eq!(reply["result"]["isError"], false, "{reply}");
}
assert!(work.join("t.scraft").is_file());
let _ = std::fs::remove_dir_all(work);
}
#[test]
fn mcp_automation_root_flags_are_checked_up_front() {
let base = tmpdir("roots-args");
@@ -118,8 +207,9 @@ fn mcp_automation_root_flags_are_checked_up_front() {
// The flags confine the in-process server, so they cannot be combined with --connect.
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
// #874: a flag may be given again, but every folder it names must exist.
fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
fails(&["--automation-write-root", dir, "--automation-write-root"], "needs a folder");
fails(&["--automation-read-root"], "needs a folder");
fails(&["--automation-write-root", "--demo"], "needs a folder");
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
@@ -138,3 +228,28 @@ fn usage_lists_the_root_flags() {
let help = String::from_utf8_lossy(&o.stdout).into_owned();
assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"), "{help}");
}
/// Without `--start`/`--end`, `--bounce` covers the whole session, not a stray edit selection
/// left by the script (a bare `19.2` in command params is 19 samples).
#[test]
fn script_bounce_without_range_covers_the_session() {
let base = tmpdir("bounce-range");
let script = base.join("cmds.txt");
std::fs::write(
&script,
concat!(
"track.new {\"count\":1,\"kind\":\"instrument\",\"name\":\"Keys\"}\n",
"midi.new_clip {\"track\":\"Keys\",\"start\":0,\"end\":{\"seconds\":19.2}}\n",
"edit.select {\"start\":0,\"end\":19.2}\n",
),
)
.unwrap();
let out = base.join("out.wav");
let o = run(&["script", script.to_str().unwrap(), "--bounce", out.to_str().unwrap()]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let stdout = String::from_utf8_lossy(&o.stdout).into_owned();
let last: Value = serde_json::from_str(stdout.lines().last().unwrap()).unwrap();
let secs = last["seconds"].as_f64().unwrap();
assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}");
let _ = std::fs::remove_dir_all(base);
}
+5
View File
@@ -0,0 +1,5 @@
//! Integration tests, built as one test binary: each file is a module.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
mod closed_stdout;
+130
View File
@@ -0,0 +1,130 @@
//! Unknown options and missing option values must be reported, not silently skipped (#549).
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn bin() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
cmd
}
fn run(args: &[&str]) -> Output {
bin().args(args).output().unwrap()
}
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-options-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A 0.1 s mono 48 kHz 16-bit WAV, written by hand so the test needs no fixture.
fn tone(dir: &Path) -> PathBuf {
let frames: u32 = 4800;
let data: Vec<u8> = (0..frames).flat_map(|_| [0x00, 0x10]).collect();
let (bytes, rate, ch, bits) = (data.len() as u32, 48_000u32, 1u16, 16u16);
let mut wav = Vec::new();
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(36 + bytes).to_le_bytes());
wav.extend_from_slice(b"WAVEfmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&ch.to_le_bytes());
wav.extend_from_slice(&rate.to_le_bytes());
wav.extend_from_slice(&(rate * u32::from(ch) * u32::from(bits) / 8).to_le_bytes());
wav.extend_from_slice(&(ch * bits / 8).to_le_bytes());
wav.extend_from_slice(&bits.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&bytes.to_le_bytes());
wav.extend_from_slice(&data);
let path = dir.join("tone.wav");
std::fs::write(&path, wav).unwrap();
path
}
fn sample_format(cli_info: &str) -> String {
serde_json::from_str::<serde_json::Value>(cli_info).unwrap()["sample_format"].as_str().unwrap().to_owned()
}
#[test]
fn convert_rejects_an_unknown_option_instead_of_writing_the_file() {
let dir = tmpdir("convert-unknown");
let input = tone(&dir);
let out = dir.join("out.wav");
for bad in ["--bitdepth", "--normalise", "--bogus"] {
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
assert!(!o.status.success(), "{bad} should fail");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("unknown option {bad}")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
assert!(!out.exists(), "{bad} should not write the output");
}
}
#[test]
fn convert_rejects_an_option_whose_value_is_missing() {
let dir = tmpdir("convert-novalue");
let input = tone(&dir);
let out = dir.join("out.wav");
for bad in ["--rate", "--bit-depth"] {
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
assert!(!o.status.success(), "{bad} should fail");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("{bad} needs a value")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
assert!(!out.exists(), "{bad} should not write the output");
}
}
#[test]
fn convert_accepts_the_inline_value_form() {
let dir = tmpdir("convert-inline");
let input = tone(&dir);
let out = dir.join("out.wav");
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth=32f"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let info = run(&["info", out.to_str().unwrap()]);
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Float32");
}
#[test]
fn convert_still_accepts_the_documented_space_separated_form() {
let dir = tmpdir("convert-space");
let input = tone(&dir);
let out = dir.join("out.wav");
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth", "16"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let info = run(&["info", out.to_str().unwrap()]);
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Int16");
}
#[test]
fn run_rejects_a_misspelled_or_value_less_bounce() {
let dir = tmpdir("run-bounce");
let out = dir.join("x.wav");
let o = run(&["run", "--cmd", "session.new", "--bounce"]);
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("--bounce needs a value"), "{}", String::from_utf8_lossy(&o.stderr));
let o = run(&["run", "--cmd", "session.new", "--bounces", out.to_str().unwrap()]);
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown option --bounces"), "{}", String::from_utf8_lossy(&o.stderr));
assert!(!out.exists(), "nothing should be bounced");
}
#[test]
fn run_bounces_with_the_inline_value_form() {
let dir = tmpdir("run-inline");
let out = dir.join("y.wav");
let o = run(&["run", "--cmd", "session.new", &format!("--bounce={}", out.display())]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
assert!(out.exists(), "--bounce=PATH should write the file");
}
#[test]
fn run_executes_the_inline_cmd_form() {
let o = run(&["run", "--cmd=track.new", "--cmd", "track.new"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let stdout = String::from_utf8_lossy(&o.stdout);
assert_eq!(stdout.lines().filter(|l| l.starts_with("track.new: ")).count(), 2, "both --cmd forms should run: {stdout}");
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SoundCraft</title>
<link data-trunk rel="icon" type="image/png" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" />
<link data-trunk rel="rust" data-bin="soundcraft-web" data-wasm-opt="z" data-weak-refs />
+6 -14
View File
@@ -1,6 +1,5 @@
//! The browser shell: the eframe web runner around [`SoundApp`].
use serde_json::json;
use soundcraft_engine::{Engine, io};
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::sampler_window::SampleRead;
@@ -166,30 +165,23 @@ impl WebShell {
}
}
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
if matches!(purpose, Purpose::Session) && soundcraft_ui_egui::bounce::refuses_session_switch(&mut self.app) {
return;
}
fn handle(&mut self, purpose: Purpose, name: String, bytes: Vec<u8>) {
let e = &mut self.app.engine;
let at = e.session().edit.selection.start.max(0);
let result = match purpose {
Purpose::Session => {
let _ = self.app.run("transport.stop", json!({}));
io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| {
if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) }
})
}
// Asks first when the open session has changes: the bytes wait for the answer.
Purpose::Session => return soundcraft_ui_egui::dialogs::open_session_bytes(&mut self.app, name, bytes),
// Imports are edits: one undo step each, like the import commands.
Purpose::Audio => {
let before = e.session_arc();
io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| {
io::import_audio_bytes(e, &name, &bytes, None, None, at).map(|_| {
e.push_undo("Import Audio", before);
format!("Imported {name}")
})
}
Purpose::Midi => {
let before = e.session_arc();
io::import_midi_bytes(e, bytes, at, true).map(|_| {
io::import_midi_bytes(e, &bytes, at, true).map(|_| {
e.push_undo("Import MIDI", before);
format!("Imported {name}")
})
@@ -205,7 +197,7 @@ impl eframe::App for WebShell {
self.take_path_prompt(ctx);
let pending = std::mem::take(&mut *self.inbox.borrow_mut());
for (purpose, name, bytes) in pending {
self.handle(purpose, &name, &bytes);
self.handle(purpose, name, bytes);
}
let samples = std::mem::take(&mut *self.samples.borrow_mut());
for (read, bytes) in samples {
+2 -1
View File
@@ -22,6 +22,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
egui.workspace = true
serde_json.workspace = true
log.workspace = true
chrono.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true
@@ -31,6 +32,6 @@ rfd.workspace = true
winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.19.2", default-features = false }
muda = { version = "0.21.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
+91 -28
View File
@@ -1,7 +1,14 @@
//! Per-user settings and automation profile directories.
use std::ffi::OsString;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use soundcraft_ui_egui::docking;
/// Portable mode (#268): this file beside the executable keeps settings, autosaves, presets and
/// logs in [`PORTABLE_DATA_DIR`] next to it instead of the user profile.
const PORTABLE_MARKER: &str = "portable.txt";
const PORTABLE_DATA_DIR: &str = "SoundCraftData";
#[derive(Clone, Copy)]
enum Platform {
@@ -13,7 +20,18 @@ enum Platform {
/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default.
/// The override is the complete directory; no application-name suffix is appended. An explicitly
/// empty value disables the settings directory instead of falling back to a personal profile.
/// Otherwise `portable.txt` beside the executable selects `<exe dir>/SoundCraftData`, when that
/// folder can be written (a read-only medium falls back to the platform default).
pub fn config_dir() -> Option<PathBuf> {
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
let exe = std::env::current_exe().ok();
resolve(exe.as_deref().and_then(Path::parent))
})
.clone()
}
fn resolve(exe_dir: Option<&Path>) -> Option<PathBuf> {
let platform = if cfg!(target_os = "macos") {
Platform::Mac
} else if cfg!(windows) {
@@ -21,13 +39,20 @@ pub fn config_dir() -> Option<PathBuf> {
} else {
Platform::Other
};
config_dir_with(platform, |name| std::env::var_os(name))
config_dir_with(platform, |name| std::env::var_os(name), exe_dir)
}
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> Option<PathBuf> {
if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") {
return (!dir.is_empty()).then(|| PathBuf::from(dir));
}
if let Some(exe_dir) = exe_dir.filter(|d| d.join(PORTABLE_MARKER).is_file()) {
let data = exe_dir.join(PORTABLE_DATA_DIR);
match writable(&data) {
Ok(()) => return Some(data),
Err(e) => log::warn!("portable mode: {} can't be written ({e}); using the user profile instead", data.display()),
}
}
match platform {
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")),
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")),
@@ -37,6 +62,14 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -
}
}
/// Create `dir` if needed and prove a file can be written in it.
fn writable(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let probe = dir.join(format!(".write-test-{}", std::process::id()));
std::fs::write(&probe, b"")?;
std::fs::remove_file(probe)
}
/// Read bounded settings, recovering unreadable docking metadata independently of other settings.
pub fn load_ui(path: &std::path::Path) -> Result<Option<soundcraft_ui_egui::UiState>, String> {
read_ui(path, true)
@@ -57,19 +90,18 @@ fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result<Option<sound
let mut saved: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?;
if recover_docking && let Some(fields) = saved.as_object_mut() {
for (field, mix) in [("docking", false), ("mix_docking", true)] {
let unreadable =
fields.get(field).is_some_and(|value| match serde_json::from_value::<soundcraft_ui_egui::docking::Workspace>(value.clone()) {
Ok(workspace) => if mix { workspace.validate_mix() } else { workspace.validate() }.is_err(),
Err(_) => true,
});
let unreadable = fields.get(field).is_some_and(|value| match serde_json::from_value::<docking::Workspace>(value.clone()) {
Ok(workspace) => if mix { docking::validate_mix(&workspace) } else { docking::validate(&workspace) }.is_err(),
Err(_) => true,
});
if unreadable {
fields.remove(field);
}
}
}
let state: soundcraft_ui_egui::UiState = serde_json::from_value(saved).map_err(|error| format!("Settings could not be restored: {error}"))?;
state.docking.validate()?;
state.mix_docking.validate_mix()?;
docking::validate(&state.docking)?;
docking::validate_mix(&state.mix_docking)?;
Ok(Some(state))
}
@@ -80,8 +112,8 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu
use std::io::Write as _;
// Loading can recover a future layout for this session, but saving must preserve its source.
read_ui(path, false)?;
ui.docking.validate()?;
ui.mix_docking.validate_mix()?;
docking::validate(&ui.docking)?;
docking::validate_mix(&ui.mix_docking)?;
let mut saved = ui.clone();
saved.plugin_windows.clear();
saved.status.clear();
@@ -129,18 +161,18 @@ mod config_tests {
app.ui = load_ui(&path).unwrap().unwrap();
assert_eq!(app.ui.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), edit);
assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
app.ui.window = MainWindow::Edit;
app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap();
assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
let mut invalid_mix = app.ui.clone();
invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default();
invalid_mix.mix_docking = soundcraft_ui_egui::docking::edit_default();
let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap();
std::fs::write(&path, &invalid_bytes).unwrap();
let recovered = load_ui(&path).unwrap().unwrap();
assert_eq!(recovered.theme, app.ui.theme);
assert_eq!(serde_json::to_value(recovered.docking).unwrap(), serde_json::to_value(&app.ui.docking).unwrap());
recovered.mix_docking.validate_mix().unwrap();
soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
assert!(save_ui(&path, &app.ui).is_err());
assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes);
std::fs::write(&path, b"damaged settings").unwrap();
@@ -170,8 +202,8 @@ mod config_tests {
let recovered = load_ui(&path).unwrap().unwrap();
assert_eq!(recovered.theme, app.ui.theme);
assert!(recovered.narrow_mix);
recovered.docking.validate().unwrap();
recovered.mix_docking.validate_mix().unwrap();
soundcraft_ui_egui::docking::validate(&recovered.docking).unwrap();
soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
let other = if field == "docking" { "mix_docking" } else { "docking" };
assert_eq!(serde_json::to_value(&recovered).unwrap()[other], saved[other]);
assert!(save_ui(&path, &recovered).is_err());
@@ -195,16 +227,20 @@ mod config_tests {
}
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), None)
}
#[test]
fn config_override_wins_without_reading_user_profile() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
let actual = config_dir_with(platform, |name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::from("isolated/settings"))
});
let actual = config_dir_with(
platform,
|name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::from("isolated/settings"))
},
None,
);
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
}
}
@@ -213,10 +249,14 @@ mod config_tests {
fn config_empty_override_disables_profile_without_reading_defaults() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
assert_eq!(
config_dir_with(platform, |name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::new())
}),
config_dir_with(
platform,
|name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::new())
},
None
),
None
);
}
@@ -234,11 +274,34 @@ mod config_tests {
}
}
#[test]
fn portable_txt_beside_the_exe_keeps_settings_next_to_it() {
let exe = std::env::temp_dir().join(format!("soundcraft-portable-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&exe);
std::fs::create_dir_all(&exe).unwrap();
let vars = [("APPDATA", "/roaming")];
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")), "no marker: the profile");
std::fs::write(exe.join("portable.txt"), b"").unwrap();
assert_eq!(resolve_with_exe(&vars, &exe), Some(exe.join("SoundCraftData")));
assert!(exe.join("SoundCraftData").is_dir());
// The explicit override still wins.
assert_eq!(resolve_with_exe(&[("SOUNDCRAFT_CONFIG_DIR", "/custom")], &exe), Some(PathBuf::from("/custom")));
// A data folder that can't be made (a file is in the way) falls back to the profile.
std::fs::remove_dir_all(exe.join("SoundCraftData")).unwrap();
std::fs::write(exe.join("SoundCraftData"), b"").unwrap();
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")));
std::fs::remove_dir_all(exe).unwrap();
}
fn resolve_with_exe(vars: &[(&str, &str)], exe: &Path) -> Option<PathBuf> {
config_dir_with(Platform::Windows, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), Some(exe))
}
#[cfg(unix)]
#[test]
fn config_override_preserves_non_unicode_paths() {
use std::os::unix::ffi::OsStringExt as _;
let value = OsString::from_vec(b"isolated/\xff".to_vec());
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone()), None), Some(PathBuf::from(value)));
}
}
+40 -18
View File
@@ -112,28 +112,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
let (y, t) = utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
/// clock can't overflow).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -533,6 +541,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
}
#[test]
+4 -2
View File
@@ -112,8 +112,10 @@ fn open_path(app: &mut SoundApp, p: &str) {
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
app.run("file.import_midi", serde_json::json!({"path": p}))
} else {
// Decoded in the background so a long file doesn't freeze the window (#597).
let at = app.engine.session().edit.selection.start;
app.run("file.import_audio", serde_json::json!({"path": p, "at": at}))
soundcraft_ui_egui::import::start(app, serde_json::json!({"path": p, "at": at}));
Ok(serde_json::Value::Null)
};
if let Err(e) = r {
log::warn!("{p}: {e}");
@@ -182,7 +184,7 @@ fn services() -> Services {
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
d = d.set_directory(dir);
}
if let Some(n) = path.file_name().and_then(|n| n.to_str()) {
if let Some(n) = path.file_name().and_then(std::ffi::OsStr::to_str) {
d = d.set_file_name(n);
}
d.save_file().map(|p| p.to_string_lossy().into_owned())
+11
View File
@@ -50,3 +50,14 @@ sync_github_name = false
[people.Momentum96]
sync_github_name = true
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.mattburlage]
display_name = "Matt Smith-Burlage"
[people.ProdByBuddha]
sync_github_name = true
+2 -2
View File
@@ -192,7 +192,7 @@ pub enum AuError {
#[cfg(target_os = "macos")]
mod api {
use super::*;
use super::{AuDescriptor, AuError, Plugin, PluginInfo, registry};
pub fn scan() -> Vec<AuDescriptor> {
registry::scan()
@@ -210,7 +210,7 @@ mod api {
#[cfg(not(target_os = "macos"))]
mod api {
use super::*;
use super::{AuDescriptor, AuError, Plugin, PluginInfo};
pub fn scan() -> Vec<AuDescriptor> {
Vec::new()
+4
View File
@@ -0,0 +1,4 @@
//! Integration tests, built as one test binary: each file is a module.
#[cfg(target_os = "macos")]
mod system_units;
@@ -1,6 +1,5 @@
//! End to end against the Audio Units Apple ships with macOS: scan → instantiate → params →
//! process → state, plus an instrument playing a note. Skips (passes) when a unit is missing.
#![cfg(target_os = "macos")]
use soundcraft_dsp::{Category, Plugin};
+14 -2
View File
@@ -185,6 +185,9 @@ pub(crate) struct Writer<W> {
quantizers: Vec<Quantizer>,
frames: u64,
bytes: Vec<u8>,
/// Set when a block write has failed: the sink may hold a prefix of that block while
/// `frames` does not count it, so the writer refuses every later call.
failed: bool,
}
impl<W: Write + Seek> Writer<W> {
@@ -193,7 +196,7 @@ impl<W: Write + Seek> Writer<W> {
let start = out.stream_position()?;
out.write_all(&header(channels, sample_rate, depth, 0)?)?;
let quantizers = Quantizer::per_channel(u32::from(sample_format(depth).0), dither, channels);
Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new() })
Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new(), failed: false })
}
/// The frame count after `frames` more, or an error when AIFF can't hold that many (4 GiB).
@@ -206,13 +209,22 @@ impl<W: Write + Seek> Writer<W> {
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
/// fit (see [`Writer::check_room`]).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
if self.failed {
return Err(crate::failed_write_error());
}
let total = self.check_room(frames as u64)?;
write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true)?;
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true) {
self.failed = true;
return Err(AudioError::from(e));
}
self.frames = total;
Ok(())
}
pub(crate) fn finish(mut self) -> Result<W> {
if self.failed {
return Err(crate::failed_write_error());
}
let data_len = u64::from(sample_format(self.depth).0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
if data_len % 2 == 1 {
self.out.write_all(&[0])?;
+60 -3
View File
@@ -3,7 +3,7 @@
//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`].
use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer};
use crate::{AudioBuffer, AudioError, BitDepth, Result};
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
use std::io::{Cursor, Seek, SeekFrom, Write};
const BLOCK_SIZE: usize = 4096;
@@ -318,6 +318,10 @@ pub(crate) struct Writer<W> {
pending: Vec<Vec<i64>>,
frames: u64,
frame_no: u64,
/// Set when a frame write has failed: `write_frame` clears the pending samples before
/// writing, so a failed frame is lost while `frames` still counts it; the writer refuses
/// every later call.
failed: bool,
}
impl<W: Write + Seek> Writer<W> {
@@ -336,6 +340,7 @@ impl<W: Write + Seek> Writer<W> {
pending: (0..channels).map(|_| Vec::with_capacity(BLOCK_SIZE)).collect(),
frames: 0,
frame_no: 0,
failed: false,
})
}
@@ -351,6 +356,9 @@ impl<W: Write + Seek> Writer<W> {
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
/// fit (see [`Writer::check_room`]).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
if self.failed {
return Err(crate::failed_write_error());
}
let total = self.check_room(frames as u64)?;
let mut done = 0;
while done < frames {
@@ -361,8 +369,11 @@ impl<W: Write + Seek> Writer<W> {
p.extend((0..n).map(|i| i64::from(q.quantize(src.get(i).copied().unwrap_or(0.0)))));
}
done += n;
if held + n == BLOCK_SIZE {
self.write_frame()?;
if held + n == BLOCK_SIZE
&& let Err(e) = self.write_frame()
{
self.failed = true;
return Err(e);
}
}
self.frames = total;
@@ -408,6 +419,9 @@ impl<W: Write + Seek> Writer<W> {
}
pub(crate) fn finish(mut self) -> Result<W> {
if self.failed {
return Err(crate::failed_write_error());
}
self.write_frame()?;
let info = stream_info(self.channels, self.sample_rate, self.bps, self.frames);
self.out.seek(SeekFrom::Start(self.start.saturating_add(4)))?;
@@ -454,6 +468,49 @@ pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> {
Ok(())
}
/// The info of a FLAC stream with no audio frames at all: STREAMINFO declares 0 samples and
/// nothing follows the metadata blocks. Symphonia's reader fails on these, as it looks for a first
/// frame, so the caller answers them itself.
pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
let mut pos = at.checked_add(4)?;
let mut first = true;
loop {
let head = be_u32(b, pos)?;
let (body, len) = (pos.checked_add(4)?, (head & 0x00ff_ffff) as usize);
let end = body.checked_add(len)?;
if end > b.len() {
return None;
}
if first && ((head >> 24) & 0x7f != 0 || len != 34) {
return None;
}
first = false;
if head >> 31 == 1 {
if end != b.len() {
return None;
}
break;
}
pos = end;
}
// STREAMINFO, from the sample rate on: 20 bits rate, 3 channels - 1, 5 bits per sample - 1, 36 bits total.
let si = at.checked_add(4 + 4 + 10)?;
let packed = u64::from_be_bytes(b.get(si..si.checked_add(8)?)?.try_into().ok()?);
let sample_rate = (packed >> 44) as u32;
let channels = ((packed >> 41) & 0x7) as u16 + 1;
let bps = ((packed >> 36) & 0x1f) as u32 + 1;
if packed & 0xf_ffff_ffff != 0 || sample_rate == 0 {
return None;
}
let sample_format = match bps {
1..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
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.
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
+11 -1
View File
@@ -115,6 +115,7 @@ pub enum BitDepth {
Int16,
Int24,
Int32,
/// Preserve finite samples, including values above full scale; map NaN to 0 and infinities to ±1.
Float32,
}
@@ -294,6 +295,13 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
}
}
/// The error a stream encoder returns for every call after one of its writes has failed: the
/// sink may already hold a prefix of the failed block, so the encoder's counters can no longer
/// describe the bytes on the sink, and later writes or `finish` must not claim success.
pub(crate) fn failed_write_error() -> AudioError {
AudioError::Encode("the stream encoder already failed on an earlier write; its output is incomplete".into())
}
/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing
/// the same bytes [`encode_with_channel_mask`] would for the whole buffer.
pub struct FileEncoder<W: Write + Seek>(Writer<W>);
@@ -329,7 +337,9 @@ impl<W: Write + Seek> FileEncoder<W> {
}
/// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are
/// silence), unless they don't fit (see [`FileEncoder::check_room`]).
/// silence), unless they don't fit (see [`FileEncoder::check_room`]). Once a write has
/// failed, the encoder is unusable: the sink may hold a prefix of the failed block, so
/// every later `write` and `finish` fails instead of completing an inconsistent file.
pub fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
match &mut self.0 {
Writer::Wav(w) => w.write(block, frames),
+11 -5
View File
@@ -193,8 +193,8 @@ pub(crate) fn write_interleaved(
for (c, q) in quantizers.iter_mut().enumerate() {
let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0);
match (is_float, big_endian) {
(true, false) => scratch.extend_from_slice(&clamp_float(s).to_le_bytes()),
(true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()),
(true, false) => scratch.extend_from_slice(&sanitize_float(s).to_le_bytes()),
(true, true) => scratch.extend_from_slice(&sanitize_float(s).to_be_bytes()),
(false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()),
(false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()),
}
@@ -206,9 +206,15 @@ pub(crate) fn write_interleaved(
Ok(())
}
/// Clamp a float sample to [-1, 1], mapping NaN to 0.
pub(crate) fn clamp_float(x: f32) -> f32 {
if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) }
/// Preserve finite float samples, mapping NaN to 0 and infinities to their sign (±1).
fn sanitize_float(x: f32) -> f32 {
if x.is_finite() {
x
} else if x.is_nan() {
0.0
} else {
x.signum()
}
}
/// Check that a buffer is encodable: at least one channel and a non-zero sample rate.
+186 -81
View File
@@ -3,18 +3,18 @@
use std::io::Cursor;
use std::panic::{AssertUnwindSafe, catch_unwind};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{
CODEC_TYPE_AAC, CODEC_TYPE_ADPCM_G722, CODEC_TYPE_ADPCM_G726, CODEC_TYPE_ADPCM_G726LE, CODEC_TYPE_ADPCM_IMA_QT, CODEC_TYPE_ADPCM_IMA_WAV,
CODEC_TYPE_ADPCM_MS, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP1, CODEC_TYPE_MP2, CODEC_TYPE_MP3, CODEC_TYPE_NULL, CODEC_TYPE_OPUS,
CODEC_TYPE_PCM_ALAW, CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE, CODEC_TYPE_PCM_MULAW,
CODEC_TYPE_VORBIS, CodecParameters, CodecType, DecoderOptions,
use symphonia::core::codecs::CodecParameters;
use symphonia::core::codecs::audio::well_known::{
CODEC_ID_AAC, CODEC_ID_ADPCM_G722, CODEC_ID_ADPCM_G726, CODEC_ID_ADPCM_G726LE, CODEC_ID_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_WAV, CODEC_ID_ADPCM_MS,
CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP1, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_ALAW, CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE,
CODEC_ID_PCM_F64BE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_MULAW, CODEC_ID_VORBIS,
};
use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO};
use symphonia::core::errors::Error as SymError;
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes};
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Instantiate;
use crate::pcm::{MAX_DECODE_SAMPLES, be_u32};
use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat};
@@ -23,6 +23,8 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat
/// takes its frame length from the file (encoders use 4096).
const MAX_PACKET_FRAMES: u64 = 1 << 20;
/// Turn a symphonia error into the audio layer's error, keeping "unsupported" and "too large"
/// distinct from a malformed file.
fn map_err(e: SymError) -> AudioError {
match e {
SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()),
@@ -36,75 +38,170 @@ fn guarded<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into())))
}
/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it
/// finds before its reader allocates whatever the file claims.
fn open(bytes: &[u8]) -> Result<Box<dyn FormatReader>> {
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
/// What [`open`] found: a reader, or a stream that holds no audio frames and has no reader.
enum Opened {
Reader(Box<dyn FormatReader>),
Empty(AudioInfo),
}
/// Skip a leading ID3v2 tag before probing.
///
/// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims, so the tag is skipped
/// with [`ReadBytes::ignore_bytes`] (which allocates nothing) instead of being handed to the
/// metadata reader. Returns the stream position the container starts at.
fn skip_leading_id3(mss: &mut MediaSourceStream<'_>, bytes: &[u8]) -> Result<usize> {
loop {
let found = symphonia::default::get_probe().next(&mut mss).map_err(map_err)?;
let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX);
let head = bytes.get(at..).unwrap_or_default();
match (found, head.get(..10)) {
(Instantiate::Format(reader), _) => {
match head.get(..4) {
Some(b"caff") => crate::caf::check(bytes, at)?,
Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?,
Some(b"fLaC") => crate::flac::check_metadata(bytes, at)?,
Some(b"RIFF") => crate::wav::check_info(bytes, at)?,
_ => {}
}
return reader(mss, &fmt_opts).map_err(map_err);
}
// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it.
(Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => {
let size = size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f));
mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
}
(Instantiate::Metadata(tag), _) => {
tag(&MetadataOptions::default()).read_all(&mut mss).map_err(map_err)?;
}
if head.len() < 10 || head.get(..3) != Some(b"ID3") {
return Ok(at);
}
let size = head.get(6..10).map(|size| size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f))).unwrap_or(0);
mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
}
}
fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat {
/// How far symphonia's probe scans for a format marker before giving up.
const MAX_PROBE_DEPTH: usize = 1024 * 1024;
/// The container marker each detected format starts with, if its reader needs size checks first.
fn container_markers(detected: FileFormat) -> &'static [&'static [u8; 4]] {
match detected {
// RF64 and BW64 are the >4 GiB RIFF forms.
FileFormat::Wav => &[b"RIFF", b"RF64", b"BW64"],
FileFormat::Flac => &[b"fLaC"],
FileFormat::Caf => &[b"caff"],
// MP4/M4A put `ftyp` after the first atom's size field.
FileFormat::Aac | FileFormat::Alac => &[b"ftyp"],
// Unknown files may carry junk before the container; symphonia's probe scans for markers.
FileFormat::Other => &[b"caff", b"ftyp", b"fLaC", b"RIFF"],
FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => &[],
}
}
/// The probe short names a container marker stands for.
fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] {
match marker {
b"caff" => &["caf"],
// The isomp4 reader reads the `ftyp` atom.
b"ftyp" => &["isomp4"],
b"fLaC" => &["flac"],
b"RIFF" | b"RF64" | b"BW64" => &["wave"],
_ => &[],
}
}
/// Probe short names whose demuxer allocates from sizes in the file, so the matching container
/// check has to run before the stream is decoded.
const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"];
/// Find the first container marker at or after `at`, within symphonia's probe depth. A RIFF form
/// other than WAVE is skipped, as the probe skips it and reads a WAVE behind it.
fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> {
let end = bytes.len().min(at.saturating_add(MAX_PROBE_DEPTH));
for pos in at..end.saturating_sub(3) {
let window = bytes.get(pos..pos + 4)?;
if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) {
if matches!(*marker, b"RIFF" | b"RF64" | b"BW64") && bytes.get(pos + 8..pos + 12) != Some(&b"WAVE"[..]) {
continue;
}
return Some((pos, *marker));
}
}
None
}
/// Probe for a container, checking the container it finds before its reader allocates whatever the
/// file claims.
///
/// The check has to run on the container the probe actually selects, but the probe reports that
/// container only through the reader it returns. The check therefore runs on the first marker the
/// detected format allows, and the reader is refused if its format is not the checked one: a
/// misleading marker must not let a later container slip through unchecked.
fn open(bytes: &[u8], detected: FileFormat) -> Result<Opened> {
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
let at = skip_leading_id3(&mut mss, bytes)?;
let mut checked: Option<&'static [&'static str]> = None;
if let Some((found, marker)) = find_marker(bytes, at, container_markers(detected)) {
match marker {
b"caff" => {
crate::caf::check(bytes, found)?;
checked = Some(expected_probe_format(marker));
}
b"ftyp" => {
crate::mp4::check(bytes, found.saturating_sub(4))?;
checked = Some(expected_probe_format(marker));
}
b"fLaC" => {
crate::flac::check_metadata(bytes, found)?;
if let Some(info) = crate::flac::empty_stream_info(bytes, found) {
return Ok(Opened::Empty(info));
}
checked = Some(expected_probe_format(marker));
}
b"RIFF" | b"RF64" | b"BW64" => {
crate::wav::check_info(bytes, found)?;
checked = Some(expected_probe_format(marker));
}
_ => {}
}
}
let reader = symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
let probed = reader.format_info().short_name;
// Only containers whose readers allocate from sizes in the file need the check. A stream the
// probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a marker
// found in its audio data must not reject it.
if let Some(names) = checked.filter(|_| SIZE_CHECKED_PROBE_FORMATS.contains(&probed)) {
if !names.contains(&probed) {
return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran")));
}
} else if checked.is_none() && SIZE_CHECKED_PROBE_FORMATS.contains(&probed) {
return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
}
Ok(Opened::Reader(reader))
}
/// The container `detected` stands for: the detection wins when it knows the file, otherwise the
/// decoded codec names the family.
fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat {
if detected != FileFormat::Other {
return detected;
}
match codec {
c if c == CODEC_TYPE_FLAC => FileFormat::Flac,
c if c == CODEC_TYPE_MP3 || c == CODEC_TYPE_MP2 || c == CODEC_TYPE_MP1 => FileFormat::Mp3,
c if c == CODEC_TYPE_AAC => FileFormat::Aac,
c if c == CODEC_TYPE_ALAC => FileFormat::Alac,
c if c == CODEC_TYPE_VORBIS => FileFormat::Ogg,
c if c == CODEC_ID_FLAC => FileFormat::Flac,
c if c == CODEC_ID_MP3 || c == CODEC_ID_MP2 || c == CODEC_ID_MP1 => FileFormat::Mp3,
c if c == CODEC_ID_AAC => FileFormat::Aac,
c if c == CODEC_ID_ALAC => FileFormat::Alac,
c if c == CODEC_ID_VORBIS => FileFormat::Ogg,
_ => FileFormat::Other,
}
}
fn sample_format_of(p: &CodecParameters) -> SampleFormat {
/// The stored sample format, using the codec and its declared bit depth. Lossy and unusual
/// codecs decode to `Compressed`.
fn sample_format_of(p: &AudioCodecParameters) -> SampleFormat {
let c = p.codec;
if c == CODEC_TYPE_PCM_F32LE || c == CODEC_TYPE_PCM_F32BE {
if c == CODEC_ID_PCM_F32LE || c == CODEC_ID_PCM_F32BE {
return SampleFormat::Float32;
}
if c == CODEC_TYPE_PCM_F64LE || c == CODEC_TYPE_PCM_F64BE {
if c == CODEC_ID_PCM_F64LE || c == CODEC_ID_PCM_F64BE {
return SampleFormat::Float64;
}
let lossy = [
CODEC_TYPE_MP1,
CODEC_TYPE_MP2,
CODEC_TYPE_MP3,
CODEC_TYPE_AAC,
CODEC_TYPE_VORBIS,
CODEC_TYPE_OPUS,
CODEC_TYPE_ADPCM_MS,
CODEC_TYPE_ADPCM_IMA_WAV,
CODEC_TYPE_ADPCM_IMA_QT,
CODEC_TYPE_ADPCM_G722,
CODEC_TYPE_ADPCM_G726,
CODEC_TYPE_ADPCM_G726LE,
CODEC_TYPE_PCM_ALAW,
CODEC_TYPE_PCM_MULAW,
CODEC_ID_MP1,
CODEC_ID_MP2,
CODEC_ID_MP3,
CODEC_ID_AAC,
CODEC_ID_VORBIS,
CODEC_ID_OPUS,
CODEC_ID_ADPCM_MS,
CODEC_ID_ADPCM_IMA_WAV,
CODEC_ID_ADPCM_IMA_QT,
CODEC_ID_ADPCM_G722,
CODEC_ID_ADPCM_G726,
CODEC_ID_ADPCM_G726LE,
CODEC_ID_PCM_ALAW,
CODEC_ID_PCM_MULAW,
];
let lossless = !lossy.contains(&c);
match (lossless, p.bits_per_sample) {
@@ -116,15 +213,18 @@ fn sample_format_of(p: &CodecParameters) -> SampleFormat {
}
}
/// The first track with a decodable audio codec, with the stream information the UI shows.
fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> {
let track = reader
let (track, p) = reader
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.find_map(|t| match t.codec_params.as_ref().and_then(CodecParameters::audio) {
Some(p) if p.codec != CODEC_ID_NULL_AUDIO => Some((t, p)),
_ => None,
})
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
let p = &track.codec_params;
let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?;
let channels = p.channels.map(|c| c.count()).unwrap_or(0);
let channels = p.channels.as_ref().map(|c| c.count()).unwrap_or(0);
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
Ok((
track.id,
@@ -133,7 +233,7 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
sample_format: sample_format_of(p),
sample_rate,
channels,
frames: p.n_frames.unwrap_or(0),
frames: track.num_frames.unwrap_or(0),
bwf: None,
},
))
@@ -141,9 +241,15 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
let mut reader = open(bytes)?;
let mut reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader,
Opened::Empty(info) => {
let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0);
return Ok((info, buf));
}
};
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) {
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.num_frames) {
let total = n.saturating_mul(u64::from(info.channels.max(1)));
if !count_only && total > MAX_DECODE_SAMPLES {
return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
@@ -153,21 +259,24 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
.tracks()
.iter()
.find(|t| t.id == track_id)
.map(|t| t.codec_params.clone())
.and_then(|t| t.codec_params.as_ref())
.and_then(CodecParameters::audio)
.cloned()
.ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_TYPE_ALAC).and_then(|d| be_u32(d, 0));
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
}
let mut decoder = symphonia::default::get_codecs().make(&params, &DecoderOptions::default()).map_err(map_err)?;
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(&params, &AudioDecoderOptions::default()).map_err(map_err)?;
let mut planar: Vec<Vec<f32>> = Vec::new();
let mut scratch: Vec<Vec<f32>> = Vec::new();
let mut frames: u64 = 0;
let mut sbuf: Option<SampleBuffer<f32>> = None;
let mut errors = 0usize;
loop {
let packet = match reader.next_packet() {
Ok(p) => p,
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => break,
Err(e) => {
@@ -178,7 +287,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
break;
}
};
if packet.track_id() != track_id {
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -194,8 +303,8 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
Err(SymError::IoError(_)) => break,
Err(e) => return Err(map_err(e)),
};
let spec = *decoded.spec();
let ch = spec.channels.count();
let spec = decoded.spec();
let ch = spec.channels().count();
if ch == 0 {
continue;
}
@@ -212,16 +321,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
if count_only {
continue;
}
let needed = decoded.capacity() as u64;
let sb = match &mut sbuf {
Some(sb) if sb.capacity() >= decoded.capacity() * ch => sb,
_ => sbuf.insert(SampleBuffer::<f32>::new(needed, spec)),
};
sb.copy_interleaved_ref(decoded);
for frame in sb.samples().chunks_exact(ch) {
for (dst, &s) in planar.iter_mut().zip(frame) {
dst.push(if s.is_finite() { s } else { 0.0 });
}
decoded.copy_to_vecs_planar::<f32>(&mut scratch);
for (dst, src) in planar.iter_mut().zip(&scratch) {
dst.extend(src.iter().map(|&s| if s.is_finite() { s } else { 0.0 }));
}
}
if info.channels == 0 {
@@ -237,7 +339,10 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
guarded(|| {
let reader = open(bytes)?;
let reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader,
Opened::Empty(info) => return Ok(info),
};
let (_, info) = info_of(reader.as_ref(), detected)?;
if info.frames > 0 && info.channels > 0 {
return Ok(info);
+81 -10
View File
@@ -10,6 +10,8 @@ const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
/// Tail of the KSDATAFORMAT_SUBTYPE_* GUIDs (bytes 2..16); bytes 0..2 hold the format tag.
const GUID_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
/// Tail of the Ambisonic subtype GUIDs {0000000x-0721-11D3-8644-C8C1CA000000}: PCM (x = 1) and float (x = 3) as well.
const AMBISONIC_TAIL: [u8; 14] = [0x00, 0x00, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0x00, 0x00, 0x00];
const BEXT_MIN_LEN: usize = 602;
/// Parsed WAV layout (no sample data decoded yet).
@@ -50,13 +52,25 @@ fn parse_fmt(c: &[u8]) -> Result<Fmt> {
fmt.valid_bits = fmt.bits;
if fmt.tag == WAVE_FORMAT_EXTENSIBLE {
// cbSize(2) validBits(2) channelMask(4) subFormat GUID(16)
if let (Some(valid), Some(sub)) = (le_u16(c, 18), le_u16(c, 24)) {
if valid != 0 {
fmt.valid_bits = valid;
}
fmt.tag = sub;
} else {
let (Some(cb_size), Some(valid), Some(sub)) = (le_u16(c, 16), le_u16(c, 18), le_u16(c, 24)) else {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
};
let Some(tail) = c.get(26..40) else {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
};
if cb_size < 22 {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE cbSize is below 22".into()));
}
if valid > fmt.bits {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE valid bits exceed the container size".into()));
}
if valid != 0 {
fmt.valid_bits = valid;
}
// A subtype outside the KSDATAFORMAT_SUBTYPE_* family is not PCM/float, whatever its first two bytes say:
// keep the extensible tag so the file goes to the foreign decoder.
if tail == GUID_TAIL || tail == AMBISONIC_TAIL {
fmt.tag = sub;
}
}
Ok(fmt)
@@ -140,6 +154,30 @@ pub(crate) fn is_wav(b: &[u8]) -> bool {
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
}
/// True when everything from `from` to the end of the file is whole RIFF chunks (four printable
/// ASCII id bytes, a size that fits, the pad byte after an odd size; the last chunk's pad may be
/// missing): the metadata of a finished file, not samples.
fn only_chunks_follow(b: &[u8], from: usize) -> bool {
let mut pos = from;
while pos < b.len() {
let (Some(id), Some(size)) = (tag(b, pos), le_u32(b, pos + 4)) else { return false };
if !id.iter().all(|c| (0x20..0x7f).contains(c)) {
return false;
}
let size = size as usize;
let Some(end) = pos.checked_add(8).and_then(|body| body.checked_add(size)) else { return false };
if end > b.len() {
return false;
}
let next = end + (size & 1);
if end == b.len() || next == b.len() {
return true;
}
pos = next;
}
false
}
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
if !is_wav(b) {
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
@@ -168,7 +206,12 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
if &id == b"data" {
// Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still
// yields whatever audio is actually present.
let len = if size32 == 0 && !is_rf64 { remaining } else { declared.min(remaining) };
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
// that was never finalised, and what follows is audio.
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);
let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) };
if data.is_none() {
data = Some((body, len));
}
@@ -321,6 +364,9 @@ pub(crate) struct Writer<W> {
quantizers: Vec<Quantizer>,
frames: u64,
bytes: Vec<u8>,
/// Set when a block write has failed: the sink may hold a prefix of that block while
/// `frames` does not count it, so the writer refuses every later call.
failed: bool,
}
impl<W: Write + Seek> Writer<W> {
@@ -339,7 +385,20 @@ impl<W: Write + Seek> Writer<W> {
let start = out.stream_position()?;
out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?;
let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels);
Ok(Writer { out, start, channels, sample_rate, format, bwf: bwf.cloned(), mask, force_rf64, quantizers, frames: 0, bytes: Vec::new() })
Ok(Writer {
out,
start,
channels,
sample_rate,
format,
bwf: bwf.cloned(),
mask,
force_rf64,
quantizers,
frames: 0,
bytes: Vec::new(),
failed: false,
})
}
fn header(&self, frames: u64) -> Result<Vec<u8>> {
@@ -355,13 +414,22 @@ impl<W: Write + Seek> Writer<W> {
/// Appends `frames` frames of planar audio (missing samples are silence).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
if self.failed {
return Err(crate::failed_write_error());
}
let total = self.check_room(frames as u64)?;
write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false)?;
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false) {
self.failed = true;
return Err(AudioError::from(e));
}
self.frames = total;
Ok(())
}
pub(crate) fn finish(mut self) -> Result<W> {
if self.failed {
return Err(crate::failed_write_error());
}
let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
if data_len % 2 == 1 {
self.out.write_all(&[0])?;
@@ -442,7 +510,10 @@ pub(crate) fn header(
if is_float {
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
}
let total = (4 + (8 + rf64_body_len) + meta.len() + 8) as u64 + data_len + (data_len & 1);
let total = ((4 + (8 + rf64_body_len) + meta.len() + 8) as u64)
.checked_add(data_len)
.and_then(|t| t.checked_add(data_len & 1))
.ok_or_else(|| AudioError::TooLarge("WAV size overflow".into()))?;
let rf64 = force_rf64 || u32::try_from(total).is_err();
if rf64 {
+101
View File
@@ -0,0 +1,101 @@
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileEncoder, FileFormat, SampleFormat, decode, encode};
use std::io::Cursor;
fn encoded(audio: &AudioBuffer, options: &EncodeOptions, streaming: bool) -> Vec<u8> {
if !streaming {
return encode(audio, options).unwrap();
}
let mut writer = FileEncoder::new(Cursor::new(Vec::new()), options, audio.num_channels(), audio.sample_rate, None).unwrap();
// Cross the writer's 4096-frame scratch boundary, then write a short final block.
for start in (0..audio.frames()).step_by(4097) {
let end = (start + 4097).min(audio.frames());
let block: Vec<Vec<f32>> = audio.channels.iter().map(|channel| channel[start..end].to_vec()).collect();
writer.write(&block, end - start).unwrap();
}
writer.finish().unwrap().into_inner()
}
fn assert_float_headroom(streaming: bool) {
let samples = [
-f32::MAX,
-2.0,
-1.25,
-1.0,
-0.5,
-f32::MIN_POSITIVE,
-f32::from_bits(1),
-0.0,
0.0,
f32::from_bits(1),
f32::MIN_POSITIVE,
0.5,
1.0,
1.25,
2.0,
f32::MAX,
];
for format in [FileFormat::Wav, FileFormat::Aiff] {
for dither in [false, true] {
for channels in [1, 2] {
let audio = AudioBuffer {
sample_rate: 48_000,
channels: (0..channels).map(|c| samples.iter().copied().cycle().skip(c).take(4103).collect()).collect(),
};
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither, bwf: None };
let bytes = encoded(&audio, &options, streaming);
let pcm: Vec<u8> =
audio.interleaved().iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
assert!(bytes.ends_with(&pcm), "{format:?}, dither={dither}: stored sample bytes changed");
let (info, decoded) = decode(&bytes, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Float32);
assert_eq!((decoded.sample_rate, decoded.num_channels(), decoded.frames()), (48_000, channels, 4103));
for (channel, (actual, expected)) in decoded.channels.iter().zip(&audio.channels).enumerate() {
for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() {
assert_eq!(actual.to_bits(), expected.to_bits(), "{format:?}, dither={dither}, channel={channel}, frame={frame}");
}
}
}
}
}
}
#[test]
fn whole_file_float32_preserves_finite_headroom() {
assert_float_headroom(false);
}
#[test]
fn streaming_float32_preserves_finite_headroom() {
assert_float_headroom(true);
}
#[test]
fn float32_keeps_non_finite_sample_handling() {
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![f32::NAN, f32::NEG_INFINITY, f32::INFINITY]] };
let expected = [0.0_f32, -1.0, 1.0];
for format in [FileFormat::Wav, FileFormat::Aiff] {
for streaming in [false, true] {
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let bytes = encoded(&audio, &options, streaming);
// Inspect stored samples too: the decoder itself replaces non-finite values.
let pcm: Vec<u8> = expected.iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
assert!(bytes.ends_with(&pcm), "{format:?}, streaming={streaming}");
assert_eq!(decode(&bytes, None).unwrap().1.channels[0], expected);
}
}
}
#[test]
fn integer_pcm_still_clips_at_full_scale() {
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![-2.0, -1.25, -1.0, -0.5, 0.0, 0.5, 1.0, 1.25, 2.0]] };
for (depth, max) in [(BitDepth::Int16, 32767.0 / 32768.0), (BitDepth::Int24, 8_388_607.0 / 8_388_608.0), (BitDepth::Int32, 1.0)] {
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
for streaming in [false, true] {
let options = EncodeOptions { format, bit_depth: depth, dither: false, bwf: None };
let bytes = encoded(&audio, &options, streaming);
let (_, decoded) = decode(&bytes, None).unwrap();
assert_eq!(decoded.channels[0], [-1.0, -1.0, -1.0, -0.5, 0.0, 0.5, max, max, max], "{format:?} {depth:?}, streaming={streaming}");
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Integration tests, built as one test binary: each file is a module.
mod encode_format;
mod float_headroom;
mod malformed;
mod roundtrip;
mod stream;
mod wav_empty_data;
@@ -328,6 +328,18 @@ fn wav_info_entries_beyond_the_file_are_refused() {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
#[test]
fn a_wave_behind_another_riff_form_is_checked() {
let info = [&b"INFO"[..], b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
let mut wav = riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]);
wav[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
let list = wav.windows(4).position(|w| w == b"LIST").unwrap();
wav[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
// The probe skips the AVI form and reads the WAVE behind it.
let f = [&b"RIFF"[..], &4u32.to_le_bytes(), b"AVI ", &wav].concat();
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
#[test]
fn wav_info_entries_are_found_where_symphonia_reads_them() {
let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
@@ -451,6 +463,20 @@ fn garbage_in_compressed_containers_errors() {
assert!(decode(&[], None).is_err());
}
/// A marker that passes its container check must not let the probe select a different container
/// without its own checks: the reader is refused when the probed format disagrees.
#[test]
fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() {
// The junk byte hides both magics, so the hint picks the marker: the FLAC is checked, but the
// probe reads the WAVE before it.
let bytes = [vec![0], small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Flac, BitDepth::Int24, 2)].concat();
match decode(&bytes, Some("flac")) {
Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"),
Err(other) => panic!("unexpected error kind: {other:?}"),
Ok(_) => panic!("the mismatched container must be refused"),
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
@@ -485,3 +511,41 @@ proptest! {
prop_assert_eq!(flac, wav);
}
}
/// An extensible `fmt ` chunk: cbSize, valid bits, mask, then the 16-byte subtype GUID.
fn extensible_fmt(cb_size: u16, valid_bits: u16, guid: &[u8]) -> Vec<u8> {
let mut f = fmt(0xFFFE, 1, 44_100, 16);
f.extend_from_slice(&cb_size.to_le_bytes());
f.extend_from_slice(&valid_bits.to_le_bytes());
f.extend_from_slice(&4u32.to_le_bytes());
f.extend_from_slice(guid);
f
}
const PCM_GUID: [u8; 16] = [1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xAA, 0, 0x38, 0x9B, 0x71];
#[test]
fn extensible_fmt_is_checked_in_full() {
let data = vec![0u8; 8];
let good = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID)), (b"data", data.clone())]);
assert_eq!(probe(&good, None).unwrap().sample_format, SampleFormat::Int16);
// Subtype GUID cut short after the format code.
let cut = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID[..4])), (b"data", data.clone())]);
assert!(matches!(probe(&cut, None), Err(AudioError::Malformed(_))));
assert!(matches!(decode(&cut, None), Err(AudioError::Malformed(_))));
// cbSize too small for the extension.
let small_cb = riff(&[(b"fmt ", extensible_fmt(0, 16, &PCM_GUID)), (b"data", data.clone())]);
assert!(matches!(probe(&small_cb, None), Err(AudioError::Malformed(_))));
// More valid bits than the 16-bit container holds.
let wide = riff(&[(b"fmt ", extensible_fmt(22, 24, &PCM_GUID)), (b"data", data.clone())]);
assert!(matches!(probe(&wide, None), Err(AudioError::Malformed(_))));
// PCM-like prefix but a foreign GUID tail is not read as native PCM.
let mut foreign = PCM_GUID;
foreign[15] ^= 0xFF;
let foreign = riff(&[(b"fmt ", extensible_fmt(22, 16, &foreign)), (b"data", data)]);
assert!(!matches!(probe(&foreign, None), Ok(i) if i.sample_format == SampleFormat::Int16));
// The Ambisonic PCM subtype is PCM.
let ambi = [1, 0, 0, 0, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0, 0, 0];
let ambi = riff(&[(b"fmt ", extensible_fmt(22, 16, &ambi)), (b"data", vec![0u8; 8])]);
assert_eq!(probe(&ambi, None).unwrap().sample_format, SampleFormat::Int16);
}
@@ -205,7 +205,7 @@ fn encode_rejects_bad_buffers_and_formats() {
for f in [FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Alac, FileFormat::Caf, FileFormat::Other] {
assert!(encode(&sine(48_000, 1, 10), &EncodeOptions { format: f, ..EncodeOptions::default() }).is_err());
}
// Non-finite and out-of-range samples are clamped, not errors.
// Non-finite samples are sanitized; only integer output clips finite samples.
let mut b = AudioBuffer::new(48_000, 1, 4);
b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5];
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
@@ -217,7 +217,7 @@ fn encode_rejects_bad_buffers_and_formats() {
let (_, out) = decode(&bytes, None).unwrap();
assert_eq!(out.channels[0][0], 0.0);
assert!(out.channels[0][1] > 0.99 && out.channels[0][1] <= 1.0);
assert_eq!(out.channels[0][2], -1.0);
assert_eq!(out.channels[0][2], if depth == BitDepth::Float32 { -5.0 } else { -1.0 });
}
}
// Ragged buffers encode the common length.
@@ -284,3 +284,35 @@ fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
assert!(wav_float_header(0, 48_000, 0).is_err());
}
#[test]
fn zero_frame_flac_round_trips_like_wav_and_aiff() {
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
for depth in [BitDepth::Int16, BitDepth::Int24] {
for channels in [1, 2] {
let opts = EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() };
let bytes = encode(&AudioBuffer::new(48_000, channels, 0), &opts).unwrap();
let info = probe(&bytes, None).unwrap();
assert_eq!((info.format, info.sample_format, info.sample_rate, info.frames), (format, expected_sf(depth), 48_000, 0));
assert_eq!(usize::from(info.channels), channels);
let (info, buf) = decode(&bytes, None).unwrap();
assert_eq!((info.frames, buf.frames(), buf.num_channels()), (0, 0, channels));
}
}
}
}
#[test]
fn flac_claiming_frames_but_holding_none_still_errors() {
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, ..EncodeOptions::default() };
let mut bytes = encode(&AudioBuffer::new(48_000, 2, 0), &opts).unwrap();
bytes[25] = 1; // STREAMINFO total samples (low byte of the 36-bit field) = 1
assert!(decode(&bytes, None).is_err());
}
#[test]
fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
// 4 bytes per frame: the data length fits in u64, but adding the header to it does not.
let frames = u64::MAX / 4;
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
}
@@ -1,5 +1,5 @@
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, BwfInfo, EncodeOptions, FileEncoder, FileFormat, encode, encode_with_channel_mask};
use std::io::Cursor;
use std::io::{Cursor, Seek, SeekFrom, Write};
fn tone(channels: usize, frames: usize) -> AudioBuffer {
let channels = (0..channels).map(|c| (0..frames).map(|i| ((i * (c + 3)) as f32 * 0.013).sin() * 1.2).collect()).collect();
@@ -49,16 +49,62 @@ fn streamed_files_match_whole_buffer_encodes() {
}
}
/// A `Write + Seek` sink that accepts `budget` bytes in total and then fails every write, so
/// a `write_all` on it appends a prefix of the block and only then reports the error.
struct FailAfter {
inner: Cursor<Vec<u8>>,
budget: usize,
}
impl Write for FailAfter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.budget == 0 {
return Err(std::io::Error::other("sink is full"));
}
let n = buf.len().min(self.budget);
self.budget -= n;
self.inner.write(&buf[..n])
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
impl Seek for FailAfter {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
#[test]
fn a_failed_write_poisons_the_encoder() {
let buf = tone(2, 20_001);
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
let sink = FailAfter { inner: Cursor::new(Vec::new()), budget: 256 };
let mut enc = FileEncoder::new(sink, &opts, 2, buf.sample_rate, None).unwrap();
// The sink accepts a prefix of the first block, then fails.
assert!(enc.write(&buf.channels, buf.frames()).is_err(), "{format:?}");
// Later calls must not claim success: the bytes on the sink no longer match the
// encoder's counters (and for FLAC the failed frame's samples are already gone).
assert!(matches!(enc.write(&buf.channels, 16), Err(AudioError::Encode(_))), "{format:?}");
assert!(matches!(enc.finish(), Err(AudioError::Encode(_))), "{format:?}");
}
}
#[test]
fn blocks_past_a_format_limit_are_refused_before_anything_is_written() {
let buf = tone(2, 100);
for (format, limit) in [(FileFormat::Aiff, 1usize << 30), (FileFormat::Flac, 1 << 36)] {
for (format, limit) in [(FileFormat::Aiff, 1u64 << 30), (FileFormat::Flac, 1u64 << 36)] {
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 2, buf.sample_rate, None).unwrap();
enc.write(&buf.channels, 100).unwrap();
assert!(enc.check_room(limit as u64 / 2).is_ok(), "{format:?}");
assert!(matches!(enc.check_room(limit as u64), Err(AudioError::TooLarge(_))), "{format:?}");
assert!(matches!(enc.write(&[], limit), Err(AudioError::TooLarge(_))), "{format:?}");
assert!(enc.check_room(limit / 2).is_ok(), "{format:?}");
assert!(matches!(enc.check_room(limit), Err(AudioError::TooLarge(_))), "{format:?}");
if let Ok(frames) = usize::try_from(limit) {
assert!(matches!(enc.write(&[], frames), Err(AudioError::TooLarge(_))), "{format:?}");
}
assert_eq!(enc.finish().unwrap().into_inner(), encode(&buf, &opts).unwrap());
}
}
@@ -0,0 +1,87 @@
//! A WAV whose data chunk is empty and followed by metadata decodes to no audio; a recording
//! that was never finalised (RIFF and data sizes left at 0) still yields what was written (#466).
use soundcraft_audio_io::{decode, probe};
fn fmt16(channels: u16, sr: u32) -> Vec<u8> {
let align = channels * 2;
let mut f = Vec::new();
f.extend_from_slice(&1u16.to_le_bytes());
f.extend_from_slice(&channels.to_le_bytes());
f.extend_from_slice(&sr.to_le_bytes());
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
f.extend_from_slice(&align.to_le_bytes());
f.extend_from_slice(&16u16.to_le_bytes());
f
}
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut c = id.to_vec();
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
c.extend_from_slice(data);
if data.len() % 2 == 1 {
c.push(0);
}
c
}
/// A finished file: correct RIFF size, chunks as given.
fn riff(chunks: &[Vec<u8>]) -> Vec<u8> {
let mut body = b"WAVE".to_vec();
for c in chunks {
body.extend_from_slice(c);
}
let mut out = b"RIFF".to_vec();
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&body);
out
}
fn list_info() -> Vec<u8> {
let mut l = b"INFO".to_vec();
l.extend_from_slice(&chunk(b"INAM", b"Silence"));
chunk(b"LIST", &l)
}
#[test]
fn an_empty_data_chunk_followed_by_metadata_has_no_frames() {
let file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[]), list_info(), chunk(b"bext", &[0u8; 602])]);
let info = probe(&file, Some("wav")).unwrap();
assert_eq!(info.frames, 0, "the LIST and bext chunks are not samples");
let (info, buf) = decode(&file, Some("wav")).unwrap();
assert_eq!((info.frames, buf.frames()), (0, 0));
// The same with the metadata first.
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), list_info(), chunk(b"data", &[])]);
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
// And with a single trailing chunk of odd size and no pad byte at the very end.
let mut file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &[]), chunk(b"note", b"abc")]);
file.pop();
let riff_size = (file.len() - 8) as u32;
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
}
#[test]
fn a_recording_that_was_never_finalised_still_gives_its_audio() {
let samples: Vec<u8> = (0..400i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
// RIFF size and data size both left at 0, as a recorder writes them until it finishes.
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
file.extend_from_slice(&samples);
file[4..8].copy_from_slice(&0u32.to_le_bytes());
let (info, buf) = decode(&file, Some("wav")).unwrap();
assert_eq!(info.frames, 200);
assert_eq!(buf.frames(), 200);
// Even if the RIFF size is the file's, audio that is not a run of chunks is still audio.
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
file.extend_from_slice(&samples);
let riff_size = (file.len() - 8) as u32;
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 200);
}
#[test]
fn a_data_chunk_with_audio_is_unchanged() {
let samples: Vec<u8> = (0..100i16).flat_map(|i| i.to_le_bytes()).collect();
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &samples), list_info()]);
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 100);
}
+84 -16
View File
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_engine::file_access::{self, AutomationRoots};
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::time::Duration;
@@ -97,46 +97,67 @@ impl Remote {
if self.conn.is_some() {
return Ok(());
}
let sock = self.addr.parse().map_err(|e| format!("{}: {e}", self.addr))?;
let s =
TcpStream::connect_timeout(&sock, Duration::from_millis(800)).map_err(|e| format!("cannot reach SoundCraft at {}: {e}", self.addr))?;
let socks: Vec<_> = self.addr.to_socket_addrs().map_err(|e| format!("{}: {e}", self.addr))?.collect();
let mut last = "no addresses found".to_string();
let mut stream = None;
for sock in &socks {
match TcpStream::connect_timeout(sock, Duration::from_millis(800)) {
Ok(s) => {
stream = Some(s);
break;
}
Err(e) => last = e.to_string(),
}
}
let s = stream.ok_or_else(|| format!("cannot reach SoundCraft at {}: {last}", self.addr))?;
s.set_read_timeout(Some(Duration::from_secs(120))).map_err(|e| e.to_string())?;
let r = s.try_clone().map_err(|e| e.to_string())?;
self.conn = Some((s, BufReader::new(r)));
Ok(())
}
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, String> {
self.connect()?;
/// One request/reply exchange. On failure the flag says whether the request line was already
/// written to the socket (so the app may have executed it).
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (String, bool)> {
self.connect().map_err(|e| (e, false))?;
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
let (w, r) = self.conn.as_mut().ok_or("not connected")?;
writeln!(w, "{line}").map_err(|e| e.to_string())?;
let (w, r) = self.conn.as_mut().ok_or_else(|| ("not connected".to_string(), false))?;
writeln!(w, "{line}").map_err(|e| (e.to_string(), false))?;
let mut buf = String::new();
r.read_line(&mut buf).map_err(|e| e.to_string())?;
r.read_line(&mut buf).map_err(|e| (e.to_string(), true))?;
if buf.is_empty() {
return Err("connection closed".into());
return Err(("connection closed".into(), true));
}
let v: Value = serde_json::from_str(&buf).map_err(|e| e.to_string())?;
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.to_string(), true))?;
if v.get("ok").and_then(Value::as_bool).unwrap_or(false) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
Err((v.get("error").and_then(Value::as_str).unwrap_or("error").to_string(), true))
}
}
}
/// Methods that only read state, so repeating them after a lost reply is harmless.
fn is_read_only(method: &str) -> bool {
matches!(method, "session.inspect" | "document.inspect" | "engine.commands" | "engine.parity" | "ui.inspect" | "ui.menu.list")
}
impl Backend for Remote {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
match self.roundtrip(method, &params) {
Ok(v) => Ok(v),
Err(e) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
// Retry once on a fresh connection.
Err((e, sent)) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
self.conn = None;
self.roundtrip(method, &params)
if sent && !is_read_only(method) {
// The app may already have executed the request; repeating it could apply it twice.
return Err(format!("{e}; `{method}` may or may not have been executed, check the session before repeating it"));
}
// Retry once on a fresh connection.
self.roundtrip(method, &params).map_err(|(e, _)| e)
}
Err(e) => Err(e),
Err((e, _)) => Err(e),
}
}
fn has_ui(&self) -> bool {
@@ -167,4 +188,51 @@ mod tests {
let mut r = Remote::new("127.0.0.1:1");
assert!(r.call("session.inspect", json!({})).is_err());
}
#[test]
fn remote_resolves_hostnames() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let mut r = Remote::new(&format!("localhost:{port}"));
assert_eq!(r.connect(), Ok(()));
}
/// Fake app: accepts one connection per entry in `replies`, counts the request line it reads,
/// then either drops the connection (`false`) or answers ok (`true`).
fn fake_app(replies: Vec<bool>) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter = seen.clone();
std::thread::spawn(move || {
for answer in replies {
let Ok((mut sock, _)) = listener.accept() else { return };
let mut line = String::new();
if BufReader::new(sock.try_clone().unwrap()).read_line(&mut line).unwrap_or(0) > 0 {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if answer {
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": true, "result": {"done": true}}));
}
}
}
});
(addr, seen)
}
#[test]
fn remote_does_not_repeat_a_mutation_after_a_lost_reply() {
let (addr, seen) = fake_app(vec![false, true]);
let mut r = Remote::new(&addr);
let err = r.call("engine.execute", json!({"command": "track.new"})).unwrap_err();
assert!(err.contains("may or may not have been executed"), "{err}");
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn remote_retries_a_read_after_a_lost_reply() {
let (addr, seen) = fake_app(vec![false, true]);
let mut r = Remote::new(&addr);
assert!(r.call("session.inspect", json!({})).is_ok());
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
}
}
+7 -3
View File
@@ -6,6 +6,7 @@ use std::io::{BufRead, Write};
pub const PROTOCOL_VERSION: &str = "2025-06-18";
const SUPPORTED: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
const MAX_BATCH_CALLS: usize = 1000;
const INSTRUCTIONS: &str = "SoundCraft is a digital audio workstation (Pro Tools-class). Everything a user can do is a \
command: call `list_commands` to discover ids and their params, then `execute` (or `batch`). Positions are samples \
@@ -37,8 +38,8 @@ pub fn tool_definitions() -> Vec<Value> {
),
tool(
"batch",
"Run several commands in order; stops at the first error unless keep_going.",
json!({"calls": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
"Run up to 1000 commands in order; stops at the first error unless keep_going. Larger batches are rejected before any command runs.",
json!({"calls": {"type": "array", "maxItems": MAX_BATCH_CALLS, "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
&["calls"],
),
tool(
@@ -135,9 +136,12 @@ impl<B: Backend> Server<B> {
let Some(calls) = args.get("calls").and_then(Value::as_array) else {
return error_result("`calls` must be an array");
};
if calls.len() > MAX_BATCH_CALLS {
return error_result(&format!("`calls` must contain at most {MAX_BATCH_CALLS} commands; no commands were executed"));
}
let mut out = Vec::new();
let mut failed = false;
for c in calls.iter().take(1000) {
for c in calls {
let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
out.push(json!({"command": "", "ok": false, "error": "`command` required"}));
failed = true;
@@ -31,6 +31,8 @@ fn handshake_and_tool_list() {
assert_eq!(r["result"]["serverInfo"]["name"], "soundcraft");
let r = rpc(&mut s, 2, "tools/list", json!({}));
assert!(r["result"]["tools"].as_array().unwrap().len() >= 10);
let batch = r["result"]["tools"].as_array().unwrap().iter().find(|tool| tool["name"] == "batch").unwrap();
assert_eq!(batch["inputSchema"]["properties"]["calls"]["maxItems"], 1000);
assert!(s.handle(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).is_none());
let r = rpc(&mut s, 3, "nope", json!({}));
assert_eq!(r["error"]["code"], -32601);
@@ -171,3 +173,35 @@ fn batch_rejects_non_array_calls() {
assert!(text.contains("`calls` must be an array"), "{text}");
assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0);
}
#[test]
fn batch_rejects_oversize_calls_before_any_mutation() {
for keep_going in [false, true] {
let mut s = Server::new(Headless::new(Engine::default()));
let before = session(&mut s);
let mut calls = vec![json!({"command": "engine.history"}); 1001];
calls[0] = json!({"command": "track.new", "params": {"name": "Must not be created"}});
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": calls, "keep_going": keep_going}}));
assert_eq!(session(&mut s), before, "oversize batch mutated the session (keep_going={keep_going})");
assert!(s.backend.engine.journal.is_empty());
assert!(!s.backend.engine.can_undo());
assert_eq!(r["result"]["isError"], true);
let text = r["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("1000"), "{text}");
}
}
#[test]
fn batch_accepts_empty_and_limit_sized_calls_in_order() {
for count in [0, 1000] {
let mut s = Server::new(Headless::new(Engine::default()));
let calls: Vec<_> = (0..count).map(|at| json!({"command": "session.format_time", "params": {"at": at, "format": "samples"}})).collect();
let results = tool(&mut s, "batch", json!({"calls": calls}));
let results = results.as_array().unwrap();
assert_eq!(results.len(), count);
for (at, result) in results.iter().enumerate() {
assert_eq!(result["ok"], true);
assert_eq!(result["result"]["text"], at.to_string());
}
}
}
@@ -1,7 +1,6 @@
//! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads
//! stays inside the read root, every file it writes inside the write root.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use soundcraft_automation::{Headless, mcp::Server};
@@ -58,7 +57,7 @@ fn fixture(name: &str) -> Fixture {
}
fn server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
let roots = AutomationRoots::new(read, write).unwrap();
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
Server::new(Headless::new(Engine::default()).with_automation_roots(roots))
}
@@ -187,7 +186,7 @@ fn writes(dir: &Path) -> Vec<(&'static str, Value)> {
}
fn demo_server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
let roots = AutomationRoots::new(read, write).unwrap();
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
Server::new(Headless::new(soundcraft_engine::demo::demo_engine()).with_automation_roots(roots))
}
+5
View File
@@ -0,0 +1,5 @@
//! Integration tests, built as one test binary: each file is a module.
mod agent_tasks;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_roots;
+1 -1
View File
@@ -23,7 +23,7 @@ harness = false
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
clap-sys = "0.5"
libloading = "0.8"
libloading = "0.9"
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
+19 -6
View File
@@ -1241,25 +1241,38 @@ impl Instance {
ok.then(|| read_name(&buf)).filter(|s| !s.is_empty())
}
pub fn audio_ports(&self, is_input: bool) -> Vec<PortInfo> {
/// The plugin's audio ports in one direction. A layout beyond `MAX_PORTS` ports or
/// `MAX_PORT_CHANNELS` channels per port is an error: we never hand a plugin fewer ports or
/// channels than it advertised.
pub fn audio_ports(&self, is_input: bool) -> Result<Vec<PortInfo>, ClapError> {
// SAFETY: see `params`.
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Vec::new() };
let (Some(count), Some(get)) = (p.count, p.get) else { return Vec::new() };
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Ok(Vec::new()) };
let (Some(count), Some(get)) = (p.count, p.get) else { return Ok(Vec::new()) };
let dir = if is_input { "input" } else { "output" };
// SAFETY: vtable call with its instance.
let n = unsafe { count(self.plugin, is_input) }.min(MAX_PORTS);
let n = unsafe { count(self.plugin, is_input) };
if n > MAX_PORTS {
return Err(ClapError::Process(format!("{n} audio {dir} ports (at most {MAX_PORTS} supported)")));
}
let mut out = Vec::new();
for i in 0..n {
// SAFETY: plain data; all-zero (null port_type pointer) is valid.
let mut info: clap_audio_port_info = unsafe { std::mem::zeroed() };
// SAFETY: index below the count; `info` is writable.
if unsafe { get(self.plugin, i, is_input, &mut info) } {
out.push(PortInfo { channels: info.channel_count.min(MAX_PORT_CHANNELS), main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
if info.channel_count > MAX_PORT_CHANNELS {
return Err(ClapError::Process(format!(
"audio {dir} port {i} has {} channels (at most {MAX_PORT_CHANNELS} supported)",
info.channel_count
)));
}
out.push(PortInfo { channels: info.channel_count, main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
} else {
// Keep port indices aligned: an unreadable port still needs a (silent) buffer slot.
out.push(PortInfo { channels: 0, main: false });
}
}
out
Ok(out)
}
pub fn note_port(&self) -> Option<NotePort> {
+2 -2
View File
@@ -88,7 +88,7 @@ pub enum ClapError {
#[cfg(not(target_arch = "wasm32"))]
mod api {
use super::*;
use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo, registry};
pub fn cached() -> Option<Vec<ClapDescriptor>> {
registry::cached()
@@ -118,7 +118,7 @@ mod api {
#[cfg(target_arch = "wasm32")]
mod api {
use super::*;
use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo};
pub fn cached() -> Option<Vec<ClapDescriptor>> {
Some(Vec::new())
+19 -3
View File
@@ -113,8 +113,21 @@ impl Plugin for ClapPlugin {
self.channels = channels.clamp(1, MAX_CHANNELS);
self.inst.deactivate();
self.inst.service_callback();
let ins = self.inst.audio_ports(true);
let outs = self.inst.audio_ports(false);
let ports = self.inst.audio_ports(true).and_then(|ins| Ok((ins, self.inst.audio_ports(false)?)));
let (ins, outs) = match ports {
Ok(p) => p,
Err(e) => {
// Unsupported layout: stay inactive (`process` passes audio through untouched).
log::warn!("{}: {e}", self.info.id);
self.inputs = PortBuffers::new(&[], 0);
self.outputs = PortBuffers::new(&[], 0);
self.in_main = None;
self.out_main = None;
self.max_block = 0;
self.latency = 0;
return;
}
};
self.in_main = pick_main(&ins);
self.out_main = pick_main(&outs);
self.inputs = PortBuffers::new(&ins.iter().map(|p| p.channels).collect::<Vec<_>>(), mb);
@@ -327,7 +340,6 @@ impl PluginEditor for ClapEditor {
fn idle(&mut self) -> Vec<(String, f32)> {
let raw = self.link.idle();
let _ = self.link.host().dirty.swap(false, Ordering::Relaxed);
let mut out: Vec<(String, f32)> = Vec::new();
for (cid, v) in raw {
let Some(i) = self.ids.iter().position(|&x| x == cid) else { continue };
@@ -341,4 +353,8 @@ impl PluginEditor for ClapEditor {
}
out
}
fn state_changed(&mut self) -> bool {
self.link.host().dirty.swap(false, Ordering::Relaxed)
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ pub fn os_search_paths(home: Option<PathBuf>, common_program_files: Option<PathB
/// True when the path names a CLAP plugin (`*.clap`, case-insensitive).
pub fn is_clap_path(p: &Path) -> bool {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("clap"))
p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("clap"))
}
/// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped).
+2 -2
View File
@@ -9,7 +9,7 @@ use std::sync::OnceLock;
pub fn fixture_dir() -> &'static Path {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/Cargo.toml");
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../clap-host/tests/fixture/Cargo.toml");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("clap-fixture-target");
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let st = Command::new(cargo)
@@ -40,7 +40,7 @@ pub fn fixture_dir() -> &'static Path {
std::fs::create_dir_all(dir.join("vendor")).unwrap();
std::fs::copy(&built, dir.join("vendor/Fixture.clap")).unwrap();
}
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 2);
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);
dir
})
}
+1 -1
View File
@@ -1,4 +1,4 @@
# A tiny CLAP plugin used only by soundcraft-clap-host's integration tests. Not part of the
# A tiny CLAP plugin used only by SoundCraft's integration tests. Not part of the
# SoundCraft workspace (the empty [workspace] table makes it its own root); the test builds it
# with `cargo build` and loads the resulting library. Never shipped.
[package]
+74 -4
View File
@@ -6,6 +6,11 @@
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
//! MIDI CC 7 (volume) sets the constant to its value / 127.
//!
//! - `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.
//!
//! 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,
//! reported as an output parameter event by the next `process`.
@@ -36,6 +41,27 @@ unsafe impl<const N: usize> Sync for Features<N> {}
static GAIN_FEATURES: Features<3> = Features([c"audio-effect".as_ptr(), c"stereo".as_ptr(), std::ptr::null()]);
static SYNTH_FEATURES: Features<3> = Features([c"instrument".as_ptr(), c"synthesizer".as_ptr(), std::ptr::null()]);
static WIDE_FEATURES: Features<2> = Features([c"audio-effect".as_ptr(), std::ptr::null()]);
const fn wide_desc(id: &'static CStr, name: &'static CStr) -> clap_plugin_descriptor {
clap_plugin_descriptor {
clap_version: CLAP_VERSION,
id: id.as_ptr(),
name: name.as_ptr(),
vendor: c"SoundCraft tests".as_ptr(),
url: std::ptr::null(),
manual_url: std::ptr::null(),
support_url: std::ptr::null(),
version: c"1.0.0".as_ptr(),
description: std::ptr::null(),
features: WIDE_FEATURES.0.as_ptr(),
}
}
static WIDE16_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide16", c"Fixture Wide 16x64");
static WIDE17_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide17ports", c"Fixture Wide 17 ports");
static WIDE65_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide65ch", c"Fixture Wide 65 channels");
static GAIN_DESC: clap_plugin_descriptor = clap_plugin_descriptor {
clap_version: CLAP_VERSION,
id: c"org.soundcraft.test.gain".as_ptr(),
@@ -66,6 +92,8 @@ struct Fx {
plugin: clap_plugin,
host: *const clap_host,
synth: bool,
/// `(ports per direction, channels per port)` for the wide fixtures.
wide: Option<(u32, u32)>,
gain: f64,
mode: f64,
level: f64,
@@ -168,6 +196,22 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
let n = pr.frames_count as usize;
let ev = unsafe { &*pr.in_events };
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
if let Some((ports, channels)) = f.wide {
let fits = |bufs: *const clap_audio_buffer, n: u32| {
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
};
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
if pr.audio_outputs_count > 0 {
let out = unsafe { &*pr.audio_outputs };
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 } };
}
}
}
return CLAP_PROCESS_CONTINUE;
}
if pr.audio_outputs_count != 1 {
return CLAP_PROCESS_ERROR;
}
@@ -324,10 +368,26 @@ static PARAMS: clap_plugin_params = clap_plugin_params {
};
unsafe extern "C" fn ports_count(p: *const clap_plugin, is_input: bool) -> u32 {
if let Some((ports, _)) = unsafe { fx(p) }.wide {
return ports;
}
if is_input && unsafe { fx(p) }.synth { 0 } else { 1 }
}
unsafe extern "C" fn ports_get(p: *const clap_plugin, index: u32, is_input: bool, info: *mut clap_audio_port_info) -> bool {
if let Some((ports, channels)) = unsafe { fx(p) }.wide {
if index >= ports {
return false;
}
let info = unsafe { &mut *info };
info.id = index * 2 + u32::from(is_input);
write_name(&mut info.name, c"wide");
info.flags = if index == 0 { CLAP_AUDIO_PORT_IS_MAIN } else { 0 };
info.channel_count = channels;
info.port_type = std::ptr::null();
info.in_place_pair = clap_sys::id::CLAP_INVALID_ID;
return true;
}
if index != 0 || (is_input && unsafe { fx(p) }.synth) {
return false;
}
@@ -498,23 +558,32 @@ unsafe extern "C" fn on_main_thread(_p: *const clap_plugin) {}
// ---- factory + entry ----
unsafe extern "C" fn factory_count(_f: *const clap_plugin_factory) -> u32 {
2
5
}
unsafe extern "C" fn factory_desc(_f: *const clap_plugin_factory, i: u32) -> *const clap_plugin_descriptor {
match i {
0 => &GAIN_DESC,
1 => &SYNTH_DESC,
2 => &WIDE16_DESC,
3 => &WIDE17_DESC,
4 => &WIDE65_DESC,
_ => std::ptr::null(),
}
}
unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const clap_host, id: *const c_char) -> *const clap_plugin {
let id = unsafe { CStr::from_ptr(id) };
let (desc, synth) = if id == c"org.soundcraft.test.gain" {
(&GAIN_DESC as *const _, false)
let (desc, synth, wide) = if id == c"org.soundcraft.test.gain" {
(&GAIN_DESC as *const _, false, None)
} else if id == c"org.soundcraft.test.synth" {
(&SYNTH_DESC as *const _, true)
(&SYNTH_DESC as *const _, true, None)
} else if id == c"org.soundcraft.test.wide16" {
(&WIDE16_DESC as *const _, false, Some((16, 64)))
} else if id == c"org.soundcraft.test.wide17ports" {
(&WIDE17_DESC as *const _, false, Some((17, 2)))
} else if id == c"org.soundcraft.test.wide65ch" {
(&WIDE65_DESC as *const _, false, Some((1, 65)))
} else {
return std::ptr::null();
};
@@ -535,6 +604,7 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
},
host,
synth,
wide,
gain: 1.0,
mode: 0.0,
level: 0.0,
@@ -3,8 +3,7 @@
use soundcraft_dsp::{Category, Unit};
mod common;
use common::fixture_dir;
use crate::common::fixture_dir;
const GAIN: &str = "clap:org.soundcraft.test.gain";
const SYNTH: &str = "clap:org.soundcraft.test.synth";
@@ -13,7 +12,7 @@ const SYNTH: &str = "clap:org.soundcraft.test.synth";
fn scan_lists_both_fixture_plugins() {
let dir = fixture_dir();
let found = soundcraft_clap_host::scan_paths(&[dir.to_path_buf()]);
assert_eq!(found.len(), 2);
assert_eq!(found.len(), 5);
let g = found.iter().find(|d| d.id == GAIN).unwrap();
assert_eq!(g.name, "Fixture Gain");
assert_eq!(g.vendor, "SoundCraft tests");
@@ -110,6 +109,27 @@ fn synth_plays_notes_at_their_offsets() {
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
}
#[test]
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
fixture_dir();
// Control: exactly at the limits (16 ports of 64 channels) activates and gets its full layout.
let mut p = soundcraft_clap_host::create("clap:org.soundcraft.test.wide16").unwrap();
p.prepare(44_100.0, 64, 2);
assert_eq!(p.latency(), 3);
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]);
// 17 ports, or a 65-channel port: never activated, never processed with a cut-down layout.
for id in ["clap:org.soundcraft.test.wide17ports", "clap:org.soundcraft.test.wide65ch"] {
let mut p = soundcraft_clap_host::create(id).unwrap();
p.prepare(44_100.0, 64, 2);
assert_eq!(p.latency(), 0, "{id}: not activated");
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == 0.5), "{id}: the plugin never ran: {:?}", &io[0][..4]);
}
}
#[test]
fn many_instances_create_and_drop_cleanly() {
fixture_dir();
+6
View File
@@ -0,0 +1,6 @@
//! Integration tests, built as one test binary: each file is a module.
#[path = "../common/mod.rs"]
mod common;
mod fixture_e2e;
+11
View File
@@ -272,9 +272,20 @@ pub trait PluginEditor: Send {
/// user closed, and returns parameter edits made in the editor as `(param id, value)` in
/// SoundCraft units (ids and ranges of [`PluginInfo::params`]).
fn idle(&mut self) -> Vec<(String, f32)>;
/// True once after the plugin said its state changed in a way its parameters don't show
/// (CLAP `state.mark_dirty`), so the session stores the new state.
fn state_changed(&mut self) -> bool {
false
}
/// Tells the editor that a parameter changed elsewhere (generic editor, automation), so the
/// GUI shows it. Values are in SoundCraft units.
fn set_param(&mut self, _id: &str, _value: f32) {}
/// The plugin's own text for a parameter value (SoundCraft units) when SoundCraft's
/// [`ParamInfo::format`] would be wrong for it. Main thread only; `None` means "format it
/// yourself".
fn value_text(&mut self, _id: &str, _value: f32) -> Option<String> {
None
}
}
/// Reads a plugin's state on the main (UI) thread while the instance processes on the audio
+196 -5
View File
@@ -65,19 +65,29 @@ pub fn normalize(ch: &mut [Vec<f32>], target_db: f32, per_channel: bool) {
for c in ch.iter_mut() {
let p = peak(std::slice::from_ref(c));
if p > 0.0 {
let g = target / p;
c.iter_mut().for_each(|s| *s *= g);
scale_to(c.iter_mut(), target, p);
}
}
} else {
let p = peak(ch);
if p > 0.0 {
let g = target / p;
ch.iter_mut().flatten().for_each(|s| *s *= g);
scale_to(ch.iter_mut().flatten(), target, p);
}
}
}
/// Scales samples whose peak is `p` to `target`. A subnormal `p` makes the f32 gain overflow to
/// infinity, so that case scales in f64.
fn scale_to<'a>(samples: impl Iterator<Item = &'a mut f32>, target: f32, p: f32) {
let g = target / p;
if g.is_finite() {
samples.for_each(|s| *s *= g);
} else {
let g = f64::from(target) / f64::from(p);
samples.for_each(|s| *s = (f64::from(*s) * g) as f32);
}
}
/// Applies a gain change in dB.
pub fn gain(ch: &mut [Vec<f32>], db: f32) {
let g = db_to_gain(if db.is_nan() { 0.0 } else { db.min(96.0) });
@@ -216,16 +226,95 @@ fn resample_ratio(ch: &[Vec<f32>], ratio: f64, out_len: usize) -> Vec<Vec<f32>>
ch.iter().map(|c| resample_channel(c, ratio, out_len, &table)).collect()
}
/// Most kernel weights a [`Polyphase`] bank may hold (16 MiB of f32).
const MAX_POLYPHASE_WEIGHTS: usize = 1 << 22;
/// The windowed-sinc kernel of [`resample_channel`] precomputed for every phase of a rational
/// rate change `from` → `to` (reduced to `step` / `phases`). Output sample `n` reads input time
/// `n * step / phases`: whole part `i`, phase `p`. Its taps are input samples `i + first ..`, one
/// weight each, so every output is one dot product over contiguous memory instead of a table
/// lookup and interpolation per tap. The weights are the ones `resample_channel` computes, taps
/// beyond the kernel's half width weigh 0, and taps are summed in the same order.
struct Polyphase {
phases: u64,
step: u64,
/// Offset of the first tap from `i`.
first: i64,
taps: usize,
/// `phases * taps` weights, phase-major.
weights: Vec<f32>,
}
impl Polyphase {
/// `None` when the bank would be too large (rates with no small common ratio); the caller
/// then falls back to [`resample_channel`].
fn new(from: u32, to: u32, table: &SincTable) -> Option<Polyphase> {
let g = gcd(from, to);
let (step, phases) = (u64::from(from / g), u64::from(to / g));
let hw = table.half_width;
let first = (-hw).floor() as i64;
let last = hw.ceil() as i64 + 1;
let taps = usize::try_from(last - first + 1).ok()?;
if usize::try_from(phases).ok()?.checked_mul(taps)? > MAX_POLYPHASE_WEIGHTS {
return None;
}
let mut weights = Vec::with_capacity(taps * phases as usize);
for p in 0..phases {
let frac = p as f64 / phases as f64;
for k in first..first + taps as i64 {
let d = frac - k as f64;
weights.push(if d.abs() <= hw { table.at(d) } else { 0.0 });
}
}
Some(Polyphase { phases, step, first, taps, weights })
}
fn run(&self, x: &[f32], out_len: usize) -> Vec<f32> {
let len = x.len() as i64;
let mut out = Vec::with_capacity(out_len);
for n in 0..out_len as u64 {
let Some(pos) = n.checked_mul(self.step) else { break };
let (i, p) = ((pos / self.phases) as i64, (pos % self.phases) as usize);
let w = self.weights.get(p * self.taps..(p + 1) * self.taps).unwrap_or_default();
let start = i + self.first;
let acc = match usize::try_from(start).ok().and_then(|s| x.get(s..s + self.taps)) {
// Inside the signal: a plain dot product.
Some(xs) => xs.iter().zip(w).fold(0.0f32, |a, (v, k)| a + v * k),
// At the edges, taps outside the signal are skipped, as `resample_channel` does.
None => (0..self.taps as i64)
.filter(|k| (0..len).contains(&(start + k)))
.fold(0.0f32, |a, k| a + x.get((start + k) as usize).copied().unwrap_or(0.0) * w.get(k as usize).copied().unwrap_or(0.0)),
};
out.push(acc);
}
out.resize(out_len, 0.0);
out
}
}
fn gcd(mut a: u32, mut b: u32) -> u32 {
while b != 0 {
(a, b) = (b, a % b);
}
a.max(1)
}
/// High-quality windowed-sinc sample-rate conversion from `from` Hz to `to` Hz.
pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
if from == 0 || to == 0 || from == to {
return ch.to_vec();
}
let ratio = f64::from(to) / f64::from(from);
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
let table = SincTable::new(cutoff);
let bank = Polyphase::new(from, to, &table);
ch.iter()
.map(|c| {
let out_len = ((c.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
resample_ratio(std::slice::from_ref(c), ratio, out_len).into_iter().next().unwrap_or_default()
match &bank {
Some(b) => b.run(c, out_len),
None => resample_channel(c, ratio, out_len, &table),
}
})
.collect()
}
@@ -363,6 +452,78 @@ pub fn pitch_shift(ch: &[Vec<f32>], semitones: f32, sample_rate: f32) -> Vec<Vec
out
}
/// Which way [`speed_ramp`] ramps the playback speed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpeedRampChange {
/// From normal speed down to a complete stop.
SlowDown,
/// From a complete stop up to normal speed.
SpeedUp,
}
/// How much of the input [`speed_ramp`] plays, and how long the result is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpeedRampSelection {
/// The result is as long as the input. Two thirds of the input are played: the first two
/// thirds for Slow Down, the last two thirds for Speed Up, so each joins its neighbour
/// without a jump.
FitTo,
/// All of the input is played, and the result is 150 % of its length.
Extend,
}
/// Plays `ch` with the speed ramping between normal speed and a complete stop, so the pitch follows
/// the speed. The speed is `1 - u²` for Slow Down and `1 - (1 - u)²` for Speed Up, where `u`
/// runs from 0 to 1 over the result; both curves average 2/3 of normal speed. With `fades`, the
/// gain follows the same curve: a fade-out for Slow Down and a fade-in for Speed Up. Every channel
/// is read at the same positions, and a channel shorter than the others is silent past its end.
pub fn speed_ramp(ch: &[Vec<f32>], change: SpeedRampChange, selection: SpeedRampSelection, fades: bool) -> Vec<Vec<f32>> {
let len = max_len(ch) as f64;
let (total, first, played) = match selection {
SpeedRampSelection::Extend => ((len * 1.5).round(), 0.0, len),
SpeedRampSelection::FitTo => {
let played = len * 2.0 / 3.0;
(len, if change == SpeedRampChange::SlowDown { 0.0 } else { len - played }, played)
}
};
let out_len = (total as usize).min(MAX_OFFLINE_SAMPLES);
let table = SincTable::new(0.97);
let hw = table.half_width;
// The fraction of the played input reached at `u`, and the speed there (0 to 1).
let at = |u: f64| -> (f64, f64) {
match change {
SpeedRampChange::SlowDown => ((3.0 * u - u * u * u) / 2.0, 1.0 - u * u),
SpeedRampChange::SpeedUp => {
let r = 1.0 - u;
(1.0 - (3.0 * r - r * r * r) / 2.0, 1.0 - r * r)
}
}
};
// The interpolation reads the first or last sample of the input for taps before or after it.
let last = (len as i64 - 1).max(0);
ch.iter()
.map(|x| {
(0..out_len)
.map(|n| {
let (pos, speed) = at(n as f64 / total);
let t = first + played * pos;
let lo = (t - hw).ceil() as i64;
let hi = (t + hw).floor() as i64;
let mut acc = 0.0f32;
let mut j = lo;
while j <= hi {
if let Some(&v) = x.get(j.clamp(0, last) as usize) {
acc += v * table.at(t - j as f64);
}
j += 1;
}
if fades { acc * speed as f32 } else { acc }
})
.collect()
})
.collect()
}
// ---------------------------------------------------------------------------------------------
// Generators and analysis
@@ -652,6 +813,36 @@ pub fn peak_db(ch: &[Vec<f32>]) -> f32 {
mod tests {
use super::*;
/// The polyphase bank gives what the per-tap kernel gives (it only moves where the weights
/// are computed), at common rate pairs, both directions, and at the signal's edges.
#[test]
fn polyphase_resample_matches_the_per_tap_kernel() {
let x: Vec<f32> = (0..3_000).map(|i| (i as f32 * 0.071).sin() * 0.6 + (i as f32 * 0.53).sin() * 0.3).collect();
let pairs = [(44_100, 48_000), (48_000, 44_100), (48_000, 96_000), (96_000, 48_000), (22_050, 48_000), (8_000, 44_100), (48_000, 22_050)];
for (from, to) in pairs {
let ratio = f64::from(to) / f64::from(from);
let out_len = ((x.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
let table = SincTable::new(cutoff);
let reference = resample_channel(&x, ratio, out_len, &table);
let bank = Polyphase::new(from, to, &table).expect("small common ratio");
let fast = bank.run(&x, out_len);
assert_eq!(fast.len(), reference.len(), "{from}->{to}");
let worst = fast.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst < 1e-5, "{from}->{to}: worst difference {worst}");
assert_eq!(resample(std::slice::from_ref(&x), from, to)[0], fast, "resample uses the bank");
}
// Signals shorter than the kernel are all edge.
let short = [0.5f32, -0.25, 0.75];
let table = SincTable::new(0.97);
let reference = resample_channel(&short, 48_000.0 / 44_100.0, 3, &table);
let fast = Polyphase::new(44_100, 48_000, &table).expect("bank").run(&short, 3);
assert!(fast.iter().zip(&reference).all(|(a, b)| (a - b).abs() < 1e-6), "{fast:?} {reference:?}");
// Rates with no small common ratio fall back to the per-tap kernel.
assert!(Polyphase::new(44_100, 96_001, &table).is_none());
assert_eq!(resample(&[short.to_vec()], 44_100, 96_001)[0].len(), 7);
}
#[test]
fn fade_shapes_endpoints() {
for s in FadeShape::ALL {
+355
View File
@@ -0,0 +1,355 @@
//! Auto Pan: moves a signal between the left and right channels with an LFO or an envelope.
use std::f32::consts::{FRAC_PI_4, PI, SQRT_2, TAU};
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
use crate::{Category, ParamInfo, Plugin, PluginInfo, Transport, Unit, db_to_gain, gain_to_db};
const SOURCE_CHOICES: &[&str] = &["LFO", "Envelope"];
const PATH_CHOICES: &[&str] = &["Left to Right", "Right to Left"];
const WAVEFORM_CHOICES: &[&str] = &["Sine", "Triangle", "Square", "Saw Up", "Saw Down", "Half Sine", "4 Step Triangle"];
const TRIGGER_CHOICES: &[&str] = &["Free", "Single", "Beat Clock", "Envelope", "Manual"];
const DURATION_CHOICES: &[&str] = &["16th Note", "8th Note", "1 Beat", "2 Beats", "1 Bar", "2 Bars", "4 Bars"];
const SOURCE_ENVELOPE: usize = 1;
const PATH_RIGHT_TO_LEFT: usize = 1;
const TRIGGER_FREE: usize = 0;
const TRIGGER_SINGLE: usize = 1;
const TRIGGER_BEAT_CLOCK: usize = 2;
const TRIGGER_ENVELOPE: usize = 3;
const TRIGGER_MANUAL: usize = 4;
static AUTOPAN_PARAMS: [ParamInfo; 14] = [
lin("output", "Output", -24.0, 12.0, 0.0, Unit::Db),
lin("width", "Width", 0.0, 100.0, 100.0, Unit::Percent),
lin("manual", "Manual", -100.0, 100.0, 0.0, Unit::Percent),
choice("source", "Source", SOURCE_CHOICES, 0),
choice("path", "Path", PATH_CHOICES, 0),
log("rate", "Rate (BPM)", 1.0, 960.0, 120.0, Unit::None),
choice("waveform", "Waveform", WAVEFORM_CHOICES, 0),
choice("trigger", "Trigger", TRIGGER_CHOICES, 0),
toggle("retrigger", "Manual Trigger", false),
toggle("link_tempo", "Link to Tempo", false),
choice("duration", "Duration", DURATION_CHOICES, 4),
lin("threshold", "Threshold", -60.0, -1.0, -30.0, Unit::Db),
log("attack", "Attack", 0.1, 100.0, 5.0, Unit::Ms),
log("release", "Release", 5.0, 2000.0, 150.0, Unit::Ms),
];
pub static AUTOPAN_INFO: PluginInfo = PluginInfo {
id: "auto_pan",
name: "Auto Pan",
short_name: "Auto Pan",
category: Category::Modulation,
params: &AUTOPAN_PARAMS,
is_instrument: false,
audiosuite: true,
};
/// The LFO shape at `t` in 0..=1 of a cycle, from -1 (the start of the path) to 1 (its end).
fn wave_at(waveform: usize, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
let tri = if t < 0.5 { 4.0 * t - 1.0 } else { 3.0 - 4.0 * t };
match waveform {
1 => tri,
2 => {
if t < 0.5 {
-1.0
} else {
1.0
}
}
3 => 2.0 * t - 1.0,
4 => 1.0 - 2.0 * t,
5 => 2.0 * (PI * t).sin() - 1.0,
6 => {
let step = ((tri + 1.0) * 2.0).floor().clamp(0.0, 3.0);
step * 2.0 / 3.0 - 1.0
}
_ => -(TAU * t).cos(),
}
}
/// Length of one Duration choice in quarter notes, for a bar of `bar` quarter notes.
fn duration_quarters(duration: usize, bar: f64) -> f64 {
match duration {
0 => 0.25,
1 => 0.5,
2 => 1.0,
3 => 2.0,
5 => 2.0 * bar,
6 => 4.0 * bar,
_ => bar,
}
}
/// Number of bars in a bar-based Duration choice.
fn duration_bars(duration: usize) -> Option<i64> {
match duration {
4 => Some(1),
5 => Some(2),
6 => Some(4),
_ => None,
}
}
/// Pans the mono sum of the first two channels between them. The pan position is
/// `manual · (1 - width) + modulator · width`, where the modulator is the LFO or the envelope
/// follower in -1..=1. A signal that is the same on both channels keeps its level at the center.
pub struct AutoPan {
p: Params,
sr: f32,
ch: usize,
transport: Transport,
/// Samples processed since the last `set_transport`, for hosts that call it less than once a block.
since_transport: u64,
was_playing: bool,
/// LFO phase in cycles, in `f64`: at the slowest rates an `f32` step is below half an ULP.
phase: f64,
/// A Single, Envelope or Manual trigger has finished its cycle and holds the last position.
holding: bool,
env: f32,
armed: bool,
/// Samples the envelope has spent below the threshold since the last Envelope trigger. The
/// trigger re-arms once this reaches the Release time, so a longer Release spaces triggers out.
below: u64,
last_retrigger: bool,
last_trigger: usize,
output: Smoother,
width: Smoother,
manual: Smoother,
/// Short declicking smoother on the pan position.
position: Smoother,
/// The next sample starts the position at its target instead of gliding to it.
snap_position: bool,
}
impl Default for AutoPan {
fn default() -> Self {
Self::new()
}
}
impl AutoPan {
pub fn new() -> Self {
let mut a = Self {
p: Params::new(&AUTOPAN_PARAMS),
sr: 48_000.0,
ch: 0,
transport: Transport::default(),
since_transport: 0,
was_playing: false,
phase: 0.0,
holding: false,
env: 0.0,
armed: true,
below: 0,
last_retrigger: false,
last_trigger: TRIGGER_FREE,
output: Smoother::new(1.0),
width: Smoother::new(1.0),
manual: Smoother::new(0.0),
position: Smoother::new(0.0),
snap_position: true,
};
a.update();
a.restart();
a
}
fn update(&mut self) {
self.output.set(db_to_gain(self.p.v(0)));
self.width.set(self.p.v(1) / 100.0);
self.manual.set(self.p.v(2) / 100.0);
let trigger = self.p.choice(7);
if trigger != self.last_trigger {
self.last_trigger = trigger;
self.restart();
}
let retrigger = self.p.on(8);
if retrigger != self.last_retrigger {
self.last_retrigger = retrigger;
if trigger == TRIGGER_MANUAL {
self.phase = 0.0;
self.holding = false;
}
}
}
/// Puts the LFO back at the start of the path. Single runs one cycle from there; Envelope and
/// Manual wait at the start for their first trigger.
fn restart(&mut self) {
self.phase = 0.0;
self.holding = matches!(self.p.choice(7), TRIGGER_ENVELOPE | TRIGGER_MANUAL);
self.armed = true;
self.below = 0;
}
/// LFO frequency in cycles per second.
fn lfo_hz(&self) -> f64 {
let tempo = if self.p.on(9) && self.transport.tempo.is_finite() && self.transport.tempo > 0.0 {
self.transport.tempo
} else {
f64::from(self.p.v(5))
};
tempo / 60.0 / duration_quarters(self.p.choice(10), self.bar_quarters())
}
fn bar_quarters(&self) -> f64 {
let (num, den) = (self.transport.numerator, self.transport.denominator);
if num == 0 || den == 0 { 4.0 } else { (f64::from(num) * 4.0 / f64::from(den)).clamp(0.25, 64.0) }
}
/// The LFO phase at `n` samples into the block when Beat Clock follows a playing transport.
/// Beat Clock restarts the LFO at the start of every Duration and runs it at Rate in between,
/// so a Rate slower than the session tempo plays only part of each cycle before the restart.
fn beat_clock_phase(&self, n: usize, hz: f64) -> Option<f64> {
let t = &self.transport;
if !(t.playing && t.tempo.is_finite() && t.tempo > 0.0 && t.quarters.is_finite()) {
return None;
}
let duration = self.p.choice(10);
let bar = self.bar_quarters();
let dur = duration_quarters(duration, bar);
let elapsed = self.since_transport.saturating_add(n as u64) as f64;
let quarters = t.quarters + elapsed * t.tempo / 60.0 / f64::from(self.sr);
// Bar-based cycles start on the bar line, so a change of time signature does not shift them;
// note and beat cycles follow the song position in quarter notes.
let into = match duration_bars(duration) {
Some(bars) => {
let in_bar = if t.bar_start.is_finite() { quarters - t.bar_start } else { quarters };
((t.bar.rem_euclid(bars) as f64 + in_bar / bar) / bars as f64).rem_euclid(1.0)
}
None => (quarters / dur).rem_euclid(1.0),
};
// Cycles of the LFO per Duration: 1 when its rate matches the session tempo.
let ratio = hz * dur * 60.0 / t.tempo;
Some((into * ratio).rem_euclid(1.0))
}
}
impl Plugin for AutoPan {
param_plumbing!(AUTOPAN_INFO);
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
self.ch = sane_channels(channels);
for s in [&mut self.output, &mut self.width, &mut self.manual] {
s.set_time(20.0, self.sr);
}
self.position.set_time(2.0, self.sr);
self.update();
self.reset();
}
fn reset(&mut self) {
self.restart();
self.env = 0.0;
for s in [&mut self.output, &mut self.width, &mut self.manual] {
s.snap();
}
self.snap_position = true;
}
fn set_transport(&mut self, transport: &Transport) {
self.transport = *transport;
self.since_transport = 0;
if transport.playing && !self.was_playing && self.p.choice(7) == TRIGGER_SINGLE {
self.restart();
}
self.was_playing = transport.playing;
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.ch.min(io.len());
let frames = frames_in(io, frames, ch);
let hz = self.lfo_hz();
let inc = if hz.is_finite() { (hz / f64::from(self.sr)).clamp(0.0, 0.5) } else { 0.0 };
let waveform = self.p.choice(6);
let trigger = self.p.choice(7);
let envelope_source = self.p.choice(3) == SOURCE_ENVELOPE;
let sign = if self.p.choice(4) == PATH_RIGHT_TO_LEFT { -1.0 } else { 1.0 };
let threshold_db = self.p.v(11);
let threshold = db_to_gain(threshold_db);
let attack = time_coef(self.p.v(12), self.sr);
let release = time_coef(self.p.v(13), self.sr);
let release_samples = ms_to_samples(self.p.v(13), self.sr) as u64;
for n in 0..frames {
let sample = |io: &[Vec<f32>], c: usize| io.get(c).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let mono = if ch >= 2 { 0.5 * (sample(io, 0) + sample(io, 1)) } else { sample(io, 0) };
let level = mono.abs();
let k = if level > self.env { attack } else { release };
self.env = level + (self.env - level) * k;
if self.env < 1.0e-12 {
self.env = 0.0;
}
if trigger == TRIGGER_ENVELOPE {
if self.armed && self.env >= threshold {
self.armed = false;
self.phase = 0.0;
self.holding = false;
self.below = 0;
} else if !self.armed {
if self.env < threshold {
self.below = self.below.saturating_add(1);
self.armed = self.below >= release_samples;
} else {
self.below = 0;
}
}
}
let lfo_phase = match (trigger == TRIGGER_BEAT_CLOCK).then(|| self.beat_clock_phase(n, hz)).flatten() {
Some(phase) => phase,
None if self.holding => self.phase,
None => {
let current = self.phase;
self.phase += inc;
if self.phase >= 1.0 {
if matches!(trigger, TRIGGER_FREE | TRIGGER_BEAT_CLOCK) {
self.phase -= 1.0;
} else {
self.phase = 1.0;
self.holding = true;
}
}
current
}
};
let modulator = if envelope_source {
let above = (gain_to_db(self.env) - threshold_db) / -threshold_db;
2.0 * above.clamp(0.0, 1.0) - 1.0
} else {
wave_at(waveform, lfo_phase as f32)
};
let width = self.width.next();
let manual = self.manual.next();
self.position.set(manual * (1.0 - width) + sign * modulator * width);
if self.snap_position {
self.snap_position = false;
self.position.snap();
}
let position = self.position.next();
let out = self.output.next();
if ch >= 2 {
// Constant power: sin² + cos² = 1 across the field.
let theta = (position.clamp(-1.0, 1.0) + 1.0) * FRAC_PI_4;
let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0));
if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = mono * l * SQRT_2 * out;
}
if let Some(x) = io.get_mut(1).and_then(|b| b.get_mut(n)) {
*x = mono * r * SQRT_2 * out;
}
for c in 2..ch {
if let Some(x) = io.get_mut(c).and_then(|b| b.get_mut(n)) {
*x = if x.is_finite() { *x * out } else { 0.0 };
}
}
} else if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = mono * out;
}
}
self.since_transport = self.since_transport.saturating_add(frames as u64);
}
}
+291 -3
View File
@@ -1,7 +1,9 @@
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction) and Rectifier.
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction), Rectifier and
//! Subharmonic (octave-down bass synthesis).
use crate::params::{Params, choice, lin, log, param_plumbing};
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr};
use crate::biquad::{Biquad, FilterType};
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr, time_coef};
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};
static SAT_PARAMS: [ParamInfo; 5] = [
@@ -307,3 +309,289 @@ impl Plugin for Rectifier {
}
}
}
// ---------------------------------------------------------------------------------------------
const SUB_RANGES: &[&str] = &["120-180 Hz", "80-120 Hz", "60-90 Hz", "40-60 Hz"];
/// Lower and upper band frequency of each range.
const SUB_RANGE_HZ: [(f32, f32); 4] = [(120.0, 180.0), (80.0, 120.0), (60.0, 90.0), (40.0, 60.0)];
const SUB_CHARACTERS: &[&str] = &["Clean I", "Clean II", "Resonant I", "Resonant II", "Distort I", "Distort II"];
/// Band gains at or below this are off.
const SUB_OFF_DB: f32 = -120.0;
/// Q of the band-pass that picks each band out of the input.
const SUB_BAND_Q: f32 = 2.0;
/// Cutoff of the low-pass after each divider, as a fraction of the band frequency.
const SUB_SMOOTH: f32 = 0.75;
/// Zero-crossing hysteresis, as a fraction of the band's envelope.
const SUB_HYSTERESIS: f32 = 0.1;
/// Release of the band envelope that sets the hysteresis.
const SUB_ENV_RELEASE_MS: f32 = 50.0;
/// Bias of the asymmetric Distort I curve.
const SUB_ASYM: f32 = 0.25;
static SUB_PARAMS: [ParamInfo; 17] = [
lin("input", "Input", -18.0, 18.0, 0.0, Unit::Db),
choice("range", "Range", SUB_RANGES, 2),
lin("lower_gain", "Lower Gain", SUB_OFF_DB, 24.0, 0.0, Unit::Db),
lin("upper_gain", "Upper Gain", SUB_OFF_DB, 24.0, -6.0, Unit::Db),
lin("direct_gain", "Direct Gain", SUB_OFF_DB, 24.0, SUB_OFF_DB, Unit::Db),
toggle("lower_solo", "Lower Solo", false),
toggle("upper_solo", "Upper Solo", false),
toggle("direct_solo", "Direct Solo", false),
lin("drive", "Drive", -18.0, 18.0, 0.0, Unit::Db),
choice("character", "Character", SUB_CHARACTERS, 0),
log("hp_freq", "HP Freq", 5.0, 1000.0, 20.0, Unit::Hz),
log("hp_q", "HP Q", 0.1, 5.0, 0.707, Unit::None),
log("lp_freq", "LP Freq", 5.0, 1000.0, 200.0, Unit::Hz),
log("lp_q", "LP Q", 0.1, 5.0, 0.707, Unit::None),
lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent),
toggle("wet_solo", "Wet Solo", false),
lin("output", "Output", -18.0, 18.0, 0.0, Unit::Db),
];
pub static SUB_INFO: PluginInfo = PluginInfo {
id: "subharmonic",
name: "Subharmonic",
short_name: "SubHarm",
category: Category::Harmonic,
params: &SUB_PARAMS,
is_instrument: false,
audiosuite: true,
};
/// One synthesized band: a band-pass, a divide-by-two flip-flop and a smoothing low-pass.
#[derive(Debug, Clone, Copy)]
struct SubBand {
band: Biquad,
smooth: Biquad,
/// Divider output: ±1, flipped on every rising zero crossing of the band.
flip: f32,
/// The band has gone below the negative threshold since the last flip.
armed: bool,
env: f32,
}
impl SubBand {
fn new() -> Self {
Self { band: Biquad::default(), smooth: Biquad::default(), flip: 1.0, armed: false, env: 0.0 }
}
fn reset(&mut self) {
self.band.reset();
self.smooth.reset();
self.flip = 1.0;
self.armed = false;
self.env = 0.0;
}
/// The octave below the band's part of `x`; `rel` is the envelope's release coefficient.
#[inline]
fn process(&mut self, x: f32, rel: f32) -> f32 {
let b = self.band.process_sample(x);
let a = b.abs();
self.env = flush(if a > self.env { a } else { self.env * rel });
let th = self.env * SUB_HYSTERESIS;
if b < -th {
self.armed = true;
} else if self.armed && b > th {
self.armed = false;
self.flip = -self.flip;
}
self.smooth.process_sample(b * self.flip)
}
}
/// Filter and divider state of one channel.
#[derive(Debug, Clone, Copy)]
struct SubChannel {
/// The lower and the upper band.
bands: [SubBand; 2],
/// Low-pass that takes the direct bass out of the input.
direct: Biquad,
hp: Biquad,
lp: Biquad,
dc: DcBlocker,
}
impl SubChannel {
fn new(sr: f32) -> Self {
Self { bands: [SubBand::new(); 2], direct: Biquad::default(), hp: Biquad::default(), lp: Biquad::default(), dc: DcBlocker::new(5.0, sr) }
}
fn reset(&mut self) {
self.bands.iter_mut().for_each(SubBand::reset);
self.direct.reset();
self.hp.reset();
self.lp.reset();
self.dc.reset();
}
}
/// Subharmonic synthesizer. The Range sets a lower and an upper band frequency. Each band
/// band-passes the input at its frequency, and a divide-by-two flip-flop on the band's rising zero
/// crossings multiplies the band, which moves it an octave down at a level that follows the band.
/// The direct band is the input low-passed at the upper band frequency.
///
/// - The three bands are summed with their gains, raised by Drive and saturated, then
/// shaped by the HP and LP filters. A soloed band plays alone and mutes the dry signal.
/// - Clean I saturates with tanh and Clean II with a softer algebraic curve. Resonant I and II use
/// tanh and raise the Q of the low-pass after each divider to 2 and 4. Distort I bends the curve
/// asymmetrically (even harmonics, DC removed) and Distort II clips hard at full scale.
/// - Below 50 % Mix the dry signal stays at full level and the wet level falls; above 50 % the wet
/// signal stays at full level and the dry level falls. Wet Solo mutes the dry signal.
pub struct SubHarmonic {
p: Params,
sr: f32,
ch: Vec<SubChannel>,
input: Smoother,
bands: [Smoother; 3],
drive: Smoother,
dry: Smoother,
wet: Smoother,
out: Smoother,
env_rel: f32,
}
impl Default for SubHarmonic {
fn default() -> Self {
Self::new()
}
}
impl SubHarmonic {
pub fn new() -> Self {
let mut s = Self {
p: Params::new(&SUB_PARAMS),
sr: 48_000.0,
ch: Vec::new(),
input: Smoother::new(1.0),
bands: [Smoother::new(0.0); 3],
drive: Smoother::new(1.0),
dry: Smoother::new(1.0),
wet: Smoother::new(1.0),
out: Smoother::new(1.0),
env_rel: 0.0,
};
s.update();
s
}
/// The lower and upper band frequencies of the current range.
fn range_hz(&self) -> (f32, f32) {
SUB_RANGE_HZ.get(self.p.choice(1)).copied().unwrap_or((60.0, 90.0))
}
/// Q of the low-pass after each divider for the current Drive Character.
fn smooth_q(&self) -> f32 {
match self.p.choice(9) {
2 => 2.0,
3 => 4.0,
_ => std::f32::consts::FRAC_1_SQRT_2,
}
}
fn update(&mut self) {
let gain = |db: f32| if db <= SUB_OFF_DB { 0.0 } else { db_to_gain(db) };
let solo = [self.p.on(5), self.p.on(6), self.p.on(7)];
let any_solo = solo.contains(&true);
for (k, s) in self.bands.iter_mut().enumerate() {
let heard = !any_solo || solo.get(k).copied().unwrap_or(false);
s.set(if heard { gain(self.p.v(2 + k)) } else { 0.0 });
}
self.input.set(db_to_gain(self.p.v(0)));
self.drive.set(db_to_gain(self.p.v(8)));
self.out.set(db_to_gain(self.p.v(16)));
let m = self.p.v(14) / 100.0;
self.wet.set((2.0 * m).min(1.0));
self.dry.set(if any_solo || self.p.on(15) { 0.0 } else { (2.0 * (1.0 - m)).min(1.0) });
self.env_rel = time_coef(SUB_ENV_RELEASE_MS, self.sr);
let (lo, hi) = self.range_hz();
let sq = self.smooth_q();
let sr = self.sr;
let (hp_f, hp_q, lp_f, lp_q) = (self.p.v(10), self.p.v(11), self.p.v(12), self.p.v(13));
for c in &mut self.ch {
for (b, f) in c.bands.iter_mut().zip([lo, hi]) {
b.band.set(FilterType::BandPass, f, SUB_BAND_Q, 0.0, sr);
b.smooth.set(FilterType::LowPass, f * SUB_SMOOTH, sq, 0.0, sr);
}
c.direct.set(FilterType::LowPass, hi, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr);
c.hp.set(FilterType::HighPass, hp_f, hp_q, 0.0, sr);
c.lp.set(FilterType::LowPass, lp_f, lp_q, 0.0, sr);
}
}
fn smoothers(&mut self) -> impl Iterator<Item = &mut Smoother> {
[&mut self.input, &mut self.drive, &mut self.dry, &mut self.wet, &mut self.out].into_iter().chain(self.bands.iter_mut())
}
}
/// Saturation curve of each Drive Character (index into `SUB_CHARACTERS`).
fn sub_saturate(character: usize, x: f32) -> f32 {
match character {
1 => x / (1.0 + x * x).sqrt(),
4 => ((x + SUB_ASYM).tanh() - SUB_ASYM.tanh()) / (1.0 + SUB_ASYM.tanh()),
5 => x.clamp(-1.0, 1.0),
_ => x.tanh(),
}
}
impl Plugin for SubHarmonic {
param_plumbing!(SUB_INFO);
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
self.ch = vec![SubChannel::new(self.sr); sane_channels(channels)];
let sr = self.sr;
for s in self.smoothers() {
s.set_time(20.0, sr);
}
self.update();
self.reset();
}
fn reset(&mut self) {
self.ch.iter_mut().for_each(SubChannel::reset);
for s in self.smoothers() {
s.snap();
}
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.ch.len().min(io.len());
let frames = frames_in(io, frames, ch);
let character = self.p.choice(9);
let rel = self.env_rel;
for n in 0..frames {
let gin = self.input.next();
let g = [self.bands[0].next(), self.bands[1].next(), self.bands[2].next()];
let drive = self.drive.next();
let dg = self.dry.next();
let wg = self.wet.next();
let o = self.out.next();
for c in 0..ch {
let (Some(buf), Some(st)) = (io.get_mut(c), self.ch.get_mut(c)) else { continue };
let Some(x) = buf.get_mut(n) else { continue };
let dry = if x.is_finite() { *x * gin } else { 0.0 };
let mut sum = st.direct.process_sample(dry) * g[2];
for (band, gain) in st.bands.iter_mut().zip(g) {
sum += band.process(dry, rel) * gain;
}
let mut y = sub_saturate(character, sum * drive);
if character == 4 {
y = st.dc.process(y);
}
let wet = st.lp.process_sample(st.hp.process_sample(y));
*x = (dry * dg + wet * wg) * o;
}
}
}
fn tail_samples(&self) -> usize {
// Time for the slowest, most resonant filter to ring down by 60 dB (6.9 time constants).
let (lo, _) = self.range_hz();
let f = self.p.v(10).min(self.p.v(12)).min(lo * SUB_SMOOTH).max(1.0);
let q = self.p.v(11).max(self.p.v(13)).max(self.smooth_q()).max(SUB_BAND_Q);
(6.9 * q / (std::f32::consts::PI * f) * self.sr) as usize
}
}
+7
View File
@@ -1,5 +1,6 @@
//! The built-in plugins and their registry.
pub mod auto_pan;
pub mod drums;
pub mod dynamics;
pub mod eq;
@@ -26,12 +27,15 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
&reverb::ROOM_INFO,
&reverb::PLATE_INFO,
&modulation::DELAY_INFO,
&auto_pan::AUTOPAN_INFO,
&modulation::CHORUS_INFO,
&modulation::FLANGER_INFO,
&modulation::PHASER_INFO,
&modulation::RING_INFO,
&harmonic::SAT_INFO,
&harmonic::LOFI_INFO,
&harmonic::RECT_INFO,
&harmonic::SUB_INFO,
&pitch::PITCH_INFO,
&utility::SHIFT_INFO,
&utility::GAIN_INFO,
@@ -58,12 +62,15 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
"room_reverb" => Box::new(reverb::Reverb::room()),
"plate_reverb" => Box::new(reverb::Reverb::plate()),
"mod_delay" => Box::new(modulation::ModDelay::new()),
"auto_pan" => Box::new(auto_pan::AutoPan::new()),
"chorus" => Box::new(modulation::ModFx::chorus()),
"flanger" => Box::new(modulation::ModFx::flanger()),
"phaser" => Box::new(modulation::Phaser::new()),
"ring_mod" => Box::new(modulation::RingMod::new()),
"saturator" => Box::new(harmonic::Saturator::new()),
"lofi" => Box::new(harmonic::LoFi::new()),
"rectifier" => Box::new(harmonic::Rectifier::new()),
"subharmonic" => Box::new(harmonic::SubHarmonic::new()),
"pitch_shifter" => Box::new(pitch::PitchShifter::new()),
"time_shift" => Box::new(utility::TimeShift::new()),
"gain" => Box::new(utility::Gain::new()),
+220 -2
View File
@@ -1,7 +1,7 @@
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser.
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser, Ring Modulator.
use crate::params::{Params, choice, lin, log, param_plumbing};
use crate::util::{DelayLine, Lfo, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr};
use crate::util::{DelayLine, Lfo, Rng, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit};
/// Per-channel LFO phase offset for stereo movement (channel 1 is offset by `spread` cycles).
@@ -459,3 +459,221 @@ impl Plugin for Phaser {
ms_to_samples(100.0, self.sr) as usize
}
}
// ---------------------------------------------------------------------------------------------
const RING_EFFECTS: &[&str] = &["Ring", "Frequency Mod", "Resonator +", "Resonator -"];
const RING_SOURCES: &[&str] = &["LFO", "Envelope", "Sample & Hold"];
const RING_MIN_HZ: f32 = 20.0;
/// Octaves the frequency moves at full modulation depth.
const RING_MOD_OCTAVES: f32 = 2.0;
/// Peak delay swing of Frequency Mod.
const RING_FM_SWING_MS: f32 = 1.0;
/// Sample-and-hold noise seed (the same sequence after every reset).
const RING_SEED: u32 = 0x5EED_1234;
static RING_PARAMS: [ParamInfo; 7] = [
choice("effect", "Effect", RING_EFFECTS, 0),
log("frequency", "Frequency", RING_MIN_HZ, 5000.0, 440.0, Unit::Hz),
lin("resonance", "Resonance", 0.0, 95.0, 50.0, Unit::Percent),
choice("mod_source", "Mod Source", RING_SOURCES, 0),
log("mod_rate", "Mod Rate", 0.05, 20.0, 1.0, Unit::Hz),
lin("mod_depth", "Mod Depth", 0.0, 100.0, 0.0, Unit::Percent),
lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent),
];
pub static RING_INFO: PluginInfo = PluginInfo {
id: "ring_mod",
name: "Ring Modulator",
short_name: "RingMod",
category: Category::Modulation,
params: &RING_PARAMS,
is_instrument: false,
audiosuite: true,
};
/// Ring modulation, frequency modulation and positive or negative comb resonators, with the
/// frequency moved by an LFO, an envelope follower or a sample-and-hold source.
///
/// - Ring multiplies the input by a sine carrier at the frequency.
/// - Frequency Mod reads the input through a delay that the carrier swings by ±1 ms.
/// - Resonator + and Resonator - feed the output back after one period of the frequency, with
/// a positive or a negative sign. The output is scaled by `1 - resonance`, so the peak gain at
/// resonance is unity.
///
/// At 100 % depth the modulation moves the frequency by up to two octaves either way. The
/// envelope follower moves it up only.
pub struct RingMod {
p: Params,
sr: f32,
lines: Vec<DelayLine>,
lfo: Lfo,
rng: Rng,
carrier: f32,
env: f32,
env_attack: f32,
env_release: f32,
held: f32,
hold_left: usize,
last_effect: usize,
freq: Smoother,
res: Smoother,
depth: Smoother,
mix: Smoother,
}
impl Default for RingMod {
fn default() -> Self {
Self::new()
}
}
impl RingMod {
pub fn new() -> Self {
let mut r = Self {
p: Params::new(&RING_PARAMS),
sr: 48_000.0,
lines: Vec::new(),
lfo: Lfo::new(),
rng: Rng::new(RING_SEED),
carrier: 0.0,
env: 0.0,
env_attack: 0.0,
env_release: 0.0,
held: 0.0,
hold_left: 0,
last_effect: 0,
freq: Smoother::new(440.0),
res: Smoother::new(0.0),
depth: Smoother::new(0.0),
mix: Smoother::new(1.0),
};
r.update();
r
}
fn update(&mut self) {
self.freq.set(self.p.v(1));
self.res.set(self.p.v(2) / 100.0);
self.depth.set(self.p.v(5) / 100.0);
self.mix.set(self.p.v(6) / 100.0);
}
/// Samples in one period of the lowest frequency the modulation can reach.
fn longest_period(&self) -> f32 {
self.sr / (self.p.v(1) * 2f32.powf(-RING_MOD_OCTAVES * self.p.v(5) / 100.0)).max(1.0)
}
}
impl Plugin for RingMod {
param_plumbing!(RING_INFO);
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
let comb = self.sr / (RING_MIN_HZ * 2f32.powf(-RING_MOD_OCTAVES));
let max = comb.max(ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr)) as usize + 8;
self.lines = (0..sane_channels(channels))
.map(|_| {
let mut d = DelayLine::default();
d.allocate(max);
d
})
.collect();
self.env_attack = time_coef(5.0, self.sr);
self.env_release = time_coef(100.0, self.sr);
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
s.set_time(20.0, self.sr);
}
self.update();
self.reset();
}
fn reset(&mut self) {
self.lines.iter_mut().for_each(DelayLine::clear);
self.lfo.reset();
self.rng = Rng::new(RING_SEED);
self.carrier = 0.0;
self.env = 0.0;
self.held = 0.0;
self.hold_left = 0;
self.last_effect = self.p.choice(0);
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
s.snap();
}
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.lines.len().min(io.len());
let frames = frames_in(io, frames, ch);
let effect = self.p.choice(0);
if effect != self.last_effect {
// The lines hold the previous effect's state.
self.lines.iter_mut().for_each(DelayLine::clear);
self.last_effect = effect;
}
let source = self.p.choice(3);
let rate = self.p.v(4);
let inc = rate / self.sr;
let hold = ((self.sr / rate.max(0.05)) as usize).max(1);
let nyq = self.sr * 0.45;
let swing = ms_to_samples(RING_FM_SWING_MS, self.sr);
for n in 0..frames {
let level = io.iter().take(ch).filter_map(|b| b.get(n)).filter(|x| x.is_finite()).fold(0.0f32, |m, x| m.max(x.abs()));
let k = if level > self.env { self.env_attack } else { self.env_release };
self.env = flush(level + (self.env - level) * k);
let lfo = Lfo::sin_at(self.lfo.tick(inc));
let m = match source {
1 => self.env.min(1.0),
2 => {
if self.hold_left == 0 {
self.held = self.rng.bipolar();
self.hold_left = hold;
}
self.hold_left -= 1;
self.held
}
_ => lfo,
};
let f = (self.freq.next() * 2f32.powf(RING_MOD_OCTAVES * self.depth.next() * m)).clamp(1.0, nyq);
let fb = self.res.next();
let mix = self.mix.next();
self.carrier += f / self.sr;
if self.carrier >= 1.0 {
self.carrier -= 1.0;
}
let carrier = Lfo::sin_at(self.carrier);
for c in 0..ch {
let (Some(buf), Some(line)) = (io.get_mut(c), self.lines.get_mut(c)) else { continue };
let Some(x) = buf.get_mut(n) else { continue };
let dry = if x.is_finite() { *x } else { 0.0 };
let wet = match effect {
1 => {
line.push(dry);
line.read_cubic(2.0 + swing * (1.0 + carrier))
}
2 | 3 => {
let sign = if effect == 2 { 1.0 } else { -1.0 };
let y = flush(dry + sign * fb * line.read_cubic(self.sr / f));
line.push(y);
y * (1.0 - fb)
}
_ => dry * carrier,
};
*x = dry * (1.0 - mix) + wet * mix;
}
}
}
fn tail_samples(&self) -> usize {
match self.p.choice(0) {
1 => ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr) as usize + 4,
2 | 3 => {
// Time for the feedback to fall by 60 dB at the longest period the modulation reaches.
let fb = (self.p.v(2) / 100.0).clamp(0.0, 0.99);
let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 };
(self.longest_period() * repeats) as usize
}
_ => 0,
}
}
}
+64 -7
View File
@@ -331,15 +331,26 @@ impl Sampler {
/// Moves a voice's read position one output frame on, looping or ending it.
#[inline]
fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) {
v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step };
let was_forward = v.forward;
v.pos = if was_forward { v.pos + v.step } else { v.pos - v.step };
match region {
Some((ls, le)) if mode == LoopMode::PingPong => {
if v.forward && v.pos >= le {
v.pos = (le - (v.pos - le)).max(ls);
v.forward = false;
} else if !v.forward && v.pos <= ls {
v.pos = (ls + (ls - v.pos)).min(le);
v.forward = true;
// How far past the edge it was heading for, measured from the edge it left. A step of
// more than the loop's width crosses it more than once: fold the distance into the
// loop (even crossings keep the direction, odd ones turn it round) instead of
// reflecting once and dropping the rest of the travel.
let width = le - ls;
let dist = if was_forward { v.pos - ls } else { le - v.pos };
if width > 0.0 && dist >= width && dist.is_finite() {
let crossings = (dist / width).floor();
let into = dist - crossings * width;
let odd = crossings % 2.0 == 1.0;
(v.pos, v.forward) = match (was_forward, odd) {
(true, false) => (ls + into, true),
(true, true) => (le - into, false),
(false, false) => (le - into, false),
(false, true) => (ls + into, true),
};
}
}
Some((ls, le)) => {
@@ -703,6 +714,52 @@ mod tests {
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
}
/// Where a ping-pong voice is after `n` steps, by walking a triangle wave over the loop.
fn triangle(start: f64, forward: bool, step: f64, n: u32, ls: f64, le: f64) -> (f64, bool) {
let width = le - ls;
// Unfold the walk onto one line, then fold it back: phase 0..width rising, width..2 width falling.
let signed = if forward { start - ls } else { (le - start) + width };
let t = (signed + f64::from(n) * step).rem_euclid(2.0 * width);
if t < width { (ls + t, true) } else { (le - (t - width), false) }
}
#[test]
fn a_ping_pong_step_wider_than_the_loop_keeps_the_phase() {
let (ls, le) = (100.0, 110.0);
// Steps below, equal to and above the loop's width, with fractions, both ways round.
for step in [0.37, 3.0, 9.99, 10.0, 10.5, 23.7, 25.0, 31.3, 99.9] {
for forward in [true, false] {
let mut v = Voice::new();
v.pos = if forward { 102.5 } else { 107.25 };
v.forward = forward;
v.step = step;
let start = v.pos;
for n in 1..=40u32 {
advance(&mut v, 1000.0, Some((ls, le)), LoopMode::PingPong);
let (want, want_forward) = triangle(start, forward, step, n, ls, le);
assert!(v.pos >= ls - 1e-9 && v.pos <= le + 1e-9, "step {step} n {n}: {} left the loop", v.pos);
// At an exact end the direction is where the position is heading next.
let at_end = (want - ls).abs() < 1e-6 || (want - le).abs() < 1e-6;
assert!((v.pos - want).abs() < 1e-6, "step {step} {forward} n {n}: at {} want {want}", v.pos);
assert!(at_end || v.forward == want_forward, "step {step} {forward} n {n}: direction");
}
}
}
}
#[test]
fn a_ping_pong_step_below_the_loop_width_moves_as_before() {
let mut v = Voice::new();
v.pos = 108.0;
v.step = 5.0;
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
assert_eq!((v.pos, v.forward), (107.0, false), "one reflection off the end");
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
assert_eq!((v.pos, v.forward), (102.0, false));
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
assert_eq!((v.pos, v.forward), (103.0, true), "and one off the start");
}
#[test]
fn a_loop_too_short_to_loop_plays_once() {
let mut s = sampler(mono(SR, vec![0.5; 1000]));
+296
View File
@@ -0,0 +1,296 @@
use std::f32::consts::PI;
use soundcraft_dsp::{Plugin, Transport, create, db_to_gain};
const SR: f32 = 48_000.0;
const BLOCK: usize = 512;
fn auto_pan(params: &[(&str, f32)]) -> Box<dyn Plugin> {
let mut p = create("auto_pan").unwrap();
p.prepare(SR, BLOCK, 2);
for (id, v) in params {
assert!(p.set_param(id, *v), "{id}");
}
p.reset();
p
}
/// Runs `seconds` of a constant `level` on both channels in host-sized blocks.
fn run_level(p: &mut dyn Plugin, level: f32, seconds: f32) -> Vec<Vec<f32>> {
let len = (seconds * SR) as usize;
let mut out = vec![Vec::new(), Vec::new()];
let mut pos = 0;
while pos < len {
let n = BLOCK.min(len - pos);
let mut buf = vec![vec![level; n]; 2];
p.process(&mut buf, n);
for c in 0..2 {
out[c].extend_from_slice(&buf[c]);
}
pos += n;
}
out
}
/// Pan position (-1 left ..= 1 right) of frame `i`, from the constant-power gains.
fn position(out: &[Vec<f32>], i: usize) -> f32 {
4.0 / PI * out[1][i].atan2(out[0][i]) - 1.0
}
fn at(seconds: f32) -> usize {
(seconds * SR) as usize
}
fn assert_near(got: f32, want: f32, tol: f32, what: &str) {
assert!((got - want).abs() <= tol, "{what}: got {got}, want {want}");
}
#[test]
fn auto_pan_manual_places_the_signal_when_width_is_zero() {
let mut p = auto_pan(&[("width", 0.0), ("manual", -100.0)]);
let out = run_level(p.as_mut(), 0.5, 0.1);
assert_near(out[0][at(0.05)], 0.5 * 2f32.sqrt(), 1e-4, "hard left, left channel");
assert_near(out[1][at(0.05)], 0.0, 1e-4, "hard left, right channel");
let mut p = auto_pan(&[("width", 0.0), ("manual", 50.0)]);
let out = run_level(p.as_mut(), 0.5, 0.1);
assert_near(position(&out, at(0.05)), 0.5, 1e-3, "manual 50 %");
let mut p = auto_pan(&[("width", 0.0)]);
let out = run_level(p.as_mut(), 0.5, 0.1);
assert_near(out[0][at(0.05)], 0.5, 1e-4, "center keeps the level on the left");
assert_near(out[1][at(0.05)], 0.5, 1e-4, "center keeps the level on the right");
}
#[test]
fn auto_pan_lfo_moves_from_left_to_right_and_back() {
// 60 BPM with a Duration of 1 beat is one cycle per second.
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0)]);
let out = run_level(p.as_mut(), 0.5, 1.0);
for (t, want) in [(0.0, -1.0), (0.25, 0.0), (0.5, 1.0), (0.75, 0.0)] {
assert_near(position(&out, at(t)), want, 0.05, &format!("left to right at {t} s"));
}
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("path", 1.0)]);
let out = run_level(p.as_mut(), 0.5, 1.0);
assert_near(position(&out, at(0.01)), 1.0, 0.05, "right to left starts on the right");
assert_near(position(&out, at(0.5)), -1.0, 0.05, "right to left at 0.5 s");
// Width 50 % sweeps half the field, and Manual moves that half to the right.
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("width", 50.0), ("manual", 50.0)]);
let out = run_level(p.as_mut(), 0.5, 1.0);
assert_near(position(&out, at(0.0)), -0.25, 0.05, "width 50 % at the start");
assert_near(position(&out, at(0.5)), 0.75, 0.05, "width 50 % at the far end");
}
#[test]
fn auto_pan_keeps_moving_at_the_slowest_rate() {
// Rate 1 BPM over 4 bars of 4/4 is one cycle in 960 s; 600 s in, a saw is 62.5 % across.
let mut p = auto_pan(&[("rate", 1.0), ("duration", 6.0), ("waveform", 3.0)]);
let mut buf = vec![vec![0.5f32; BLOCK]; 2];
for _ in 0..(600.0 * SR) as usize / BLOCK {
buf.iter_mut().for_each(|c| c.fill(0.5));
p.process(&mut buf, BLOCK);
}
assert_near(position(&buf, BLOCK - 1), 0.25, 0.01, "600 s into a 960 s cycle");
}
#[test]
fn auto_pan_waveforms_take_their_shapes() {
let shape = |waveform: f32, t: f32| {
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("waveform", waveform)]);
let out = run_level(p.as_mut(), 0.5, 1.0);
position(&out, at(t))
};
assert_near(shape(1.0, 0.25), 0.0, 0.05, "triangle at a quarter cycle");
assert_near(shape(1.0, 0.125), -0.5, 0.05, "triangle at an eighth of a cycle");
assert_near(shape(2.0, 0.2), -1.0, 0.02, "square in its first half");
assert_near(shape(2.0, 0.7), 1.0, 0.02, "square in its second half");
assert_near(shape(3.0, 0.25), -0.5, 0.05, "saw up");
assert_near(shape(4.0, 0.25), 0.5, 0.05, "saw down");
assert_near(shape(5.0, 1.0 / 6.0), 0.0, 0.05, "half sine reaches the center at a sixth of a cycle");
assert_near(shape(6.0, 0.1), -1.0, 0.02, "4 step triangle, first step");
assert_near(shape(6.0, 0.2), -1.0 / 3.0, 0.02, "4 step triangle, second step");
assert_near(shape(6.0, 0.45), 1.0, 0.02, "4 step triangle, top step");
}
#[test]
fn auto_pan_rate_follows_the_session_tempo_and_duration() {
// Linked to a 120 BPM session with a Duration of 1 beat: two cycles per second.
let mut p = auto_pan(&[("link_tempo", 1.0), ("rate", 30.0), ("duration", 2.0)]);
p.set_transport(&Transport { tempo: 120.0, numerator: 4, denominator: 4, ..Transport::default() });
let out = run_level(p.as_mut(), 0.5, 0.5);
assert_near(position(&out, at(0.25)), 1.0, 0.05, "linked to 120 BPM, 1 beat");
// Not linked: Rate 120 BPM and the default Duration of 1 bar (4/4) is one cycle in 2 s.
let mut p = auto_pan(&[]);
p.set_transport(&Transport { tempo: 60.0, numerator: 4, denominator: 4, ..Transport::default() });
let out = run_level(p.as_mut(), 0.5, 1.5);
assert_near(position(&out, at(1.0)), 1.0, 0.05, "Rate 120 BPM, 1 bar");
// A 3/4 bar at 120 BPM lasts 1.5 s.
let mut p = auto_pan(&[("link_tempo", 1.0)]);
p.set_transport(&Transport { tempo: 120.0, numerator: 3, denominator: 4, ..Transport::default() });
let out = run_level(p.as_mut(), 0.5, 1.0);
assert_near(position(&out, at(0.75)), 1.0, 0.05, "1 bar of 3/4");
}
#[test]
fn auto_pan_beat_clock_follows_the_transport_position() {
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("duration", 2.0)]);
// Half a beat into the song, the LFO is half way through its cycle.
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 7.5, ..Transport::default() });
let out = run_level(p.as_mut(), 0.5, 0.01);
assert_near(position(&out, 0), 1.0, 0.05, "half a beat in");
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("duration", 2.0)]);
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 3.0, ..Transport::default() });
let out = run_level(p.as_mut(), 0.5, 0.2);
assert_near(position(&out, 0), -1.0, 0.05, "on the beat");
assert_near(position(&out, at(0.125)), 0.0, 0.05, "a quarter of a beat later");
// Not linked, Rate 60 BPM in a 120 BPM session: the LFO restarts on every beat (0.5 s) and
// runs at 1 Hz in between, so it reaches the right and then starts again from the left.
let mut p = auto_pan(&[("trigger", 2.0), ("rate", 60.0), ("duration", 2.0)]);
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 3.0, ..Transport::default() });
let out = run_level(p.as_mut(), 0.5, 1.0);
assert_near(position(&out, at(0.25)), 0.0, 0.05, "a quarter cycle at 1 Hz");
assert_near(position(&out, at(0.49)), 1.0, 0.05, "on the right at the end of the beat");
assert_near(position(&out, at(0.51)), -1.0, 0.05, "restarted on the next beat");
assert_near(position(&out, at(0.75)), 0.0, 0.05, "a quarter cycle after the restart");
// 2 Beats in 3/4, with the host updating the transport every block: the cycle runs on across
// the bar line at quarter 3 instead of restarting there.
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0), ("duration", 3.0)]);
let mut buf = vec![vec![0.5f32; 100]; 2];
let mut positions = Vec::new();
for block in 0..480 {
let quarters = 2.0 + block as f64 * 100.0 * 2.0 / f64::from(SR);
let bar = (quarters / 3.0).floor();
p.set_transport(&Transport {
playing: true,
tempo: 120.0,
numerator: 3,
denominator: 4,
quarters,
bar_start: bar * 3.0,
bar: bar as i64,
..Transport::default()
});
buf.iter_mut().for_each(|c| c.fill(0.5));
p.process(&mut buf, 100);
positions.push((quarters, position(&buf, 0)));
}
for (q, want) in [(2.5, -0.5), (3.0, 0.0), (3.5, 0.5)] {
let got = positions.iter().find(|(quarters, _)| (quarters - q).abs() < 1e-9).map(|(_, pos)| *pos).unwrap();
assert_near(got, want, 0.02, &format!("2 beats in 3/4 at quarter {q}"));
}
// Bar-based cycles start on the bar line after a change from 4/4 to 3/4 at quarter 4.
let three_four = |quarters: f64| Transport {
playing: true,
tempo: 120.0,
numerator: 3,
denominator: 4,
quarters,
bar_start: 4.0,
bar: 1,
..Transport::default()
};
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0)]);
p.set_transport(&three_four(4.0));
let out = run_level(p.as_mut(), 0.5, 0.01);
assert_near(position(&out, 0), -1.0, 0.02, "1 bar starts on the 3/4 bar line");
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0), ("duration", 5.0)]);
p.set_transport(&three_four(5.5));
let out = run_level(p.as_mut(), 0.5, 0.01);
assert_near(position(&out, 0), 0.5, 0.02, "half way through the second bar of 2 bars");
}
#[test]
fn auto_pan_single_runs_one_cycle_and_holds() {
let mut p = auto_pan(&[("trigger", 1.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0)]);
let out = run_level(p.as_mut(), 0.5, 2.0);
assert_near(position(&out, at(0.5)), 0.0, 0.05, "half way through the one cycle");
assert_near(position(&out, at(1.5)), 1.0, 0.02, "holds at the end of the path");
assert_near(position(&out, at(1.99)), 1.0, 0.02, "still holding");
// Starting the transport runs the cycle again.
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, ..Transport::default() });
let out = run_level(p.as_mut(), 0.5, 0.5);
assert_near(position(&out, at(0.25)), -0.5, 0.05, "restarted when playback starts");
}
#[test]
fn auto_pan_envelope_source_pans_by_level() {
let settled = |level: f32| {
let mut p = auto_pan(&[("source", 1.0), ("threshold", -30.0)]);
let out = run_level(p.as_mut(), level, 0.5);
position(&out, at(0.45))
};
assert_near(settled(db_to_gain(-40.0)), -1.0, 0.02, "below the threshold");
assert_near(settled(db_to_gain(-15.0)), 0.0, 0.05, "half way to full scale");
assert_near(settled(1.0), 1.0, 0.02, "full scale");
}
#[test]
fn auto_pan_envelope_and_manual_triggers_start_a_cycle() {
let mut p = auto_pan(&[("trigger", 3.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0), ("threshold", -30.0)]);
let quiet = run_level(p.as_mut(), db_to_gain(-40.0), 0.5);
assert_near(position(&quiet, at(0.4)), -1.0, 0.02, "waits at the start below the threshold");
let loud = run_level(p.as_mut(), 1.0, 1.5);
assert_near(position(&loud, at(0.25)), -0.5, 0.05, "a quarter cycle after the level crosses the threshold");
assert_near(position(&loud, at(1.4)), 1.0, 0.02, "holds after one cycle while the level stays up");
let mut p = auto_pan(&[("trigger", 4.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0)]);
let before = run_level(p.as_mut(), 0.5, 0.5);
assert_near(position(&before, at(0.4)), -1.0, 0.02, "waits for the manual trigger");
assert!(p.set_param("retrigger", 1.0));
let after = run_level(p.as_mut(), 0.5, 0.5);
assert_near(position(&after, at(0.25)), -0.5, 0.05, "a quarter cycle after switching the trigger on");
let held = run_level(p.as_mut(), 0.5, 1.0);
assert!(p.set_param("retrigger", 0.0));
let again = run_level(p.as_mut(), 0.5, 0.5);
assert_near(position(&held, at(0.9)), 1.0, 0.02, "holds after one cycle");
assert_near(position(&again, at(0.25)), -0.5, 0.05, "switching the trigger off starts another cycle");
}
#[test]
fn auto_pan_envelope_trigger_waits_for_the_release() {
let mut p = auto_pan(&[
("trigger", 3.0),
("rate", 60.0),
("duration", 2.0),
("waveform", 3.0),
("threshold", -30.0),
("attack", 0.1),
("release", 100.0),
]);
let quiet = db_to_gain(-40.0);
let first = run_level(p.as_mut(), 1.0, 1.5);
assert_near(position(&first, at(1.4)), 1.0, 0.02, "the first cycle ends and holds");
// The envelope falls below the threshold about 0.38 s into the quiet part, so a 0.42 s dip
// leaves it below for less than the 100 ms Release.
run_level(p.as_mut(), quiet, 0.42);
let short = run_level(p.as_mut(), 1.0, 0.5);
assert_near(position(&short, at(0.25)), 1.0, 0.02, "no trigger before the release is over");
run_level(p.as_mut(), quiet, 1.0);
let long = run_level(p.as_mut(), 1.0, 0.5);
assert_near(position(&long, at(0.25)), -0.5, 0.05, "a trigger once the release is over");
}
#[test]
fn auto_pan_output_gain_and_mono_tracks() {
let mut p = auto_pan(&[("width", 0.0), ("output", 6.0)]);
let out = run_level(p.as_mut(), 0.5, 0.1);
assert_near(out[0][at(0.05)], 0.5 * db_to_gain(6.0), 1e-4, "+6 dB at the center");
// A mono insert has nothing to pan to; Output still applies.
let mut p = create("auto_pan").unwrap();
p.prepare(SR, BLOCK, 1);
assert!(p.set_param("output", -6.0));
p.reset();
let mut buf = vec![vec![0.5f32; BLOCK]];
p.process(&mut buf, BLOCK);
assert_near(buf[0][BLOCK - 1], 0.5 * db_to_gain(-6.0), 1e-4, "mono output");
}
@@ -46,6 +46,9 @@ proptest! {
let _ = offline::detect_transients(&ch, a, a);
let _ = offline::time_stretch(&ch, r, a);
let _ = offline::pitch_shift(&ch, a, a);
let change = if n % 2 == 0 { offline::SpeedRampChange::SpeedUp } else { offline::SpeedRampChange::SlowDown };
let selection = if n % 3 == 0 { offline::SpeedRampSelection::Extend } else { offline::SpeedRampSelection::FitTo };
let _ = offline::speed_ramp(&ch, change, selection, n % 5 == 0);
let _ = offline::resample(&ch, n as u32, (n as u32).wrapping_mul(3) % 200_000);
let mut m = ch.clone();
offline::normalize(&mut m, a, n % 2 == 0);
+9
View File
@@ -0,0 +1,9 @@
//! Integration tests, built as one test binary: each file is a module.
mod auto_pan;
mod fuzz;
mod offline_meters;
mod offline_normalize;
mod plugins;
mod speed_ramp;
mod transients;
+19
View File
@@ -0,0 +1,19 @@
use soundcraft_dsp::offline::{normalize, peak};
#[test]
fn normalize_subnormal_peak_stays_finite() {
for per_channel in [false, true] {
let mut c = vec![vec![1e-44f32, -1e-44]];
normalize(&mut c, 0.0, per_channel);
assert!(c[0].iter().all(|s| s.is_finite()), "{c:?}");
assert!((peak(&c) - 1.0).abs() < 1e-3, "{c:?}");
}
}
#[test]
fn normalize_silent_and_normal_channels() {
let mut c = vec![vec![0.0f32; 4], vec![0.25, -0.5]];
normalize(&mut c, 0.0, true);
assert_eq!(c[0], vec![0.0; 4]);
assert!((peak(&c[1..]) - 1.0).abs() < 1e-5);
}
@@ -513,6 +513,103 @@ fn saturator_adds_harmonics_and_stays_bounded() {
assert!(spec[third - 1..=third + 1].iter().cloned().fold(f32::MIN, f32::max) > -30.0);
}
/// Amplitude of the `freq` component of `x` (a whole number of cycles keeps it exact).
fn tone_level(x: &[f32], freq: f32) -> f32 {
let (mut re, mut im) = (0.0f64, 0.0f64);
for (i, v) in x.iter().enumerate() {
let w = f64::from(TAU * freq / SR) * i as f64;
re += f64::from(*v) * w.cos();
im += f64::from(*v) * w.sin();
}
(2.0 * (re * re + im * im).sqrt() / x.len() as f64) as f32
}
/// A Subharmonic with only the wet path and wide filters, so a band's octave reaches the output.
fn subharmonic_wet() -> Box<dyn Plugin> {
let mut p = prepared("subharmonic");
p.set_param("mix", 100.0);
p.set_param("hp_freq", 5.0);
p.set_param("lp_freq", 1000.0);
p
}
#[test]
fn subharmonic_adds_the_octave_below_each_range_band() {
for (range, lower, upper) in [(0.0, 120.0, 180.0), (1.0, 80.0, 120.0), (2.0, 60.0, 90.0), (3.0, 40.0, 60.0)] {
for (band, freq) in [("lower", lower), ("upper", upper)] {
let mut p = subharmonic_wet();
p.set_param("range", range);
p.set_param("lower_gain", if band == "lower" { 0.0 } else { -120.0 });
p.set_param("upper_gain", if band == "upper" { 0.0 } else { -120.0 });
let out = run(p.as_mut(), &[sine(freq, 0.5, 96_000)]);
// One second after the filters settle: a whole number of cycles of both tones.
let tail = &out[0][48_000..];
let sub = tone_level(tail, freq / 2.0);
assert!(sub > 0.2, "range {range} {band} band: {sub} at {} Hz", freq / 2.0);
assert!(sub > 4.0 * tone_level(tail, freq), "range {range} {band} band keeps its input tone");
}
}
}
#[test]
fn subharmonic_mix_keeps_one_path_at_full_level() {
// A 1 kHz tone is far above every band, so the output is the dry path alone.
for (mix, dry) in [(0.0, 1.0), (25.0, 1.0), (50.0, 1.0), (75.0, 0.5), (100.0, 0.0)] {
let mut p = prepared("subharmonic");
p.set_param("mix", mix);
let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]);
let level = tone_level(&out[0][24_000..], 1000.0);
assert!((level - 0.5 * dry).abs() < 0.01, "mix {mix}: dry level {level}");
}
// The wet path is at full level from 50 % up and at half level at 25 %.
let wet = |mix: f32| {
let mut p = prepared("subharmonic");
p.set_param("mix", mix);
p.set_param("wet_solo", 1.0);
let out = run(p.as_mut(), &[sine(60.0, 0.5, 96_000)]);
tone_level(&out[0][48_000..], 30.0)
};
let full = wet(50.0);
assert!(full > 0.1, "wet level {full}");
assert!((wet(100.0) - full).abs() < 0.01 * full);
assert!((wet(25.0) - 0.5 * full).abs() < 0.01 * full);
}
#[test]
fn subharmonic_solo_plays_one_band_and_mutes_the_dry_signal() {
let level = |solo: &str, gain: f32| {
let mut p = prepared("subharmonic");
p.set_param("lower_gain", gain);
p.set_param(solo, 1.0);
let x = sine(60.0, 0.5, 96_000).iter().zip(sine(1000.0, 0.5, 96_000)).map(|(a, b)| a + b).collect();
let out = run(p.as_mut(), &[x]);
let tail = &out[0][48_000..];
(tone_level(tail, 30.0), tone_level(tail, 1000.0))
};
let (sub, dry) = level("lower_solo", 0.0);
assert!(sub > 0.1 && dry < 0.01, "lower solo: sub {sub}, dry {dry}");
// With the soloed band turned off nothing else plays, though the upper band is on.
let (sub, dry) = level("lower_solo", -120.0);
assert!(sub < 0.01 && dry < 0.01, "silent lower solo: sub {sub}, dry {dry}");
let (sub, dry) = level("upper_solo", -120.0);
assert!(sub > 0.05 && dry < 0.01, "upper solo: sub {sub}, dry {dry}");
}
#[test]
fn subharmonic_drive_keeps_every_character_bounded() {
for character in 0..6 {
let mut p = subharmonic_wet();
p.set_param("input", 18.0);
p.set_param("lower_gain", 24.0);
p.set_param("upper_gain", 24.0);
p.set_param("drive", 18.0);
p.set_param("character", character as f32);
let out = run(p.as_mut(), &[sine(60.0, 1.0, 48_000)]);
let top = peak(&out[0][24_000..]);
assert!(top > 0.5 && top < 1.5, "character {character}: peak {top}");
}
}
#[test]
fn lofi_quantizes() {
let mut p = prepared("lofi");
@@ -524,3 +621,95 @@ fn lofi_quantizes() {
levels.dedup();
assert!(levels.len() <= 5, "{levels:?}");
}
fn ring_mod(params: &[(&str, f32)]) -> Box<dyn Plugin> {
let mut p = prepared("ring_mod");
for &(id, v) in params {
assert!(p.set_param(id, v), "ring_mod.{id}");
}
p.reset();
p
}
/// Loudest bin within two bins of `hz` in an 8192-point spectrum.
fn band_db(spec: &[f32], hz: f32) -> f32 {
let bin = (hz / (SR / 8192.0)).round() as usize;
spec[bin - 2..=bin + 2].iter().cloned().fold(f32::MIN, f32::max)
}
fn zero_crossings(x: &[f32]) -> usize {
x.windows(2).filter(|w| (w[0] < 0.0) != (w[1] < 0.0)).count()
}
#[test]
fn ring_mod_makes_sum_and_difference_tones() {
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 300.0), ("mod_depth", 0.0), ("mix", 100.0)]);
let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]);
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
let (low, high, input) = (band_db(&spec, 700.0), band_db(&spec, 1300.0), band_db(&spec, 1000.0));
// The two tones fall at different places between bins, so their bin peaks differ a little.
assert!((low - high).abs() < 2.0, "700 Hz {low} dB, 1300 Hz {high} dB");
assert!(low - input > 40.0, "the 1000 Hz input is left at {input} dB against {low} dB");
}
#[test]
fn ring_mod_frequency_mod_adds_sidebands() {
let x = sine(1000.0, 0.5, 48_000);
let dry = soundcraft_dsp::spectrum::magnitude_db(&x[8192..], 8192);
assert!(band_db(&dry, 1000.0) - band_db(&dry, 1200.0) > 40.0);
let mut p = ring_mod(&[("effect", 1.0), ("frequency", 200.0), ("mix", 100.0)]);
let out = run(p.as_mut(), &[x]);
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
for hz in [800.0, 1200.0] {
assert!(band_db(&spec, 1000.0) - band_db(&spec, hz) < 20.0, "no sideband at {hz} Hz");
}
}
#[test]
fn ring_mod_resonators_ring_at_the_frequency_with_their_sign() {
// 480 Hz at 48 kHz is a 100-sample period.
for (effect, sign) in [(2.0, 1.0), (3.0, -1.0)] {
let mut p = ring_mod(&[("effect", effect), ("frequency", 480.0), ("resonance", 90.0), ("mix", 100.0)]);
let mut x = vec![0.0f32; 9600];
x[0] = 1.0;
let out = run(p.as_mut(), &[x]);
let y = &out[0];
assert!((y[0] - 0.1).abs() < 1e-4, "effect {effect}: direct {}", y[0]);
assert!((y[100] - sign * 0.09).abs() < 1e-4, "effect {effect}: first echo {}", y[100]);
assert!((y[200] - 0.081).abs() < 1e-4, "effect {effect}: second echo {}", y[200]);
assert!(y[50].abs() < 1e-6 && y[150].abs() < 1e-6, "effect {effect}: sound between the echoes");
let last = y.iter().rposition(|v| v.abs() > 0.1e-3).unwrap();
assert!(last < p.tail_samples(), "effect {effect}: ringing at {last}, tail {}", p.tail_samples());
}
}
#[test]
fn ring_mod_modulation_sources_move_the_frequency() {
// A constant input through Ring gives the carrier itself.
let dc = vec![1.0f32; 48_000];
// The envelope of a full-scale input lifts 100 Hz by one octave at 50 % depth.
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 1.0), ("mod_depth", 50.0), ("mix", 100.0)]);
let out = run(p.as_mut(), std::slice::from_ref(&dc));
let n = zero_crossings(&out[0][24_000..]);
assert!((195..=205).contains(&n), "envelope: {n} crossings in 0.5 s, 200 expected for 200 Hz");
// A 1 Hz LFO raises the frequency for its first half cycle and lowers it for the second.
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 0.0), ("mod_rate", 1.0), ("mod_depth", 100.0), ("mix", 100.0)]);
let out = run(p.as_mut(), std::slice::from_ref(&dc));
let (up, down) = (zero_crossings(&out[0][..12_000]), zero_crossings(&out[0][24_000..36_000]));
assert!(up > 3 * down, "LFO: {up} crossings rising, {down} falling");
// Sample and hold at 4 Hz keeps one frequency for each 12,000-sample step.
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 2.0), ("mod_rate", 4.0), ("mod_depth", 100.0), ("mix", 100.0)]);
let out = run(p.as_mut(), std::slice::from_ref(&dc));
let mut steps = Vec::new();
for s in out[0].chunks(12_000) {
let (a, b) = (zero_crossings(&s[..6000]), zero_crossings(&s[6000..]));
assert!(a.abs_diff(b) <= 2, "sample and hold changed within a step: {a} then {b}");
steps.push(a);
}
steps.sort_unstable();
steps.dedup_by(|a, b| a.abs_diff(*b) <= 2);
assert!(steps.len() >= 3, "sample and hold steps: {steps:?}");
}
+119
View File
@@ -0,0 +1,119 @@
use soundcraft_dsp::offline::{self, SpeedRampChange, SpeedRampSelection};
/// A channel whose samples rise from 0 to 1, so each output sample shows where it was read.
fn ramp(len: usize) -> Vec<f32> {
(0..len).map(|i| i as f32 / len as f32).collect()
}
/// The fraction of the played input reached at `u` (0 to 1) of the result.
fn slow_down_pos(u: f64) -> f64 {
(3.0 * u - u * u * u) / 2.0
}
fn speed_up_pos(u: f64) -> f64 {
1.0 - slow_down_pos(1.0 - u)
}
fn near(got: f32, want: f64, what: &str) {
assert!((f64::from(got) - want).abs() < 2e-3, "{what}: got {got}, want {want}");
}
#[test]
fn speed_ramp_fit_to_keeps_the_length_and_plays_two_thirds() {
let len = 30_000;
let x = vec![ramp(len)];
let slow = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, false);
assert_eq!(slow[0].len(), len);
// Slow Down plays the first two thirds: it starts at normal speed at the start and stops at the end.
for u in [0.0, 0.25, 0.5, 0.75, 0.9] {
let n = (u * len as f64) as usize;
near(slow[0][n], 2.0 / 3.0 * slow_down_pos(u), &format!("slow down at {u}"));
}
// The speed over 50 samples: a one-sample step inside the input also shows the interpolation's ripple.
let step = |a: &[f32], n: usize| f64::from(a[n + 50] - a[n]) * len as f64 / 50.0;
assert!((step(&slow[0], 100) - 1.0).abs() < 0.01, "slow down starts at normal speed: {}", step(&slow[0], 100));
assert!(step(&slow[0], len - 200) < 0.02, "slow down ends near a stop: {}", step(&slow[0], len - 200));
// Speed Up plays the last two thirds: it starts from a stop and reaches normal speed at the end.
let fast = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, false);
assert_eq!(fast[0].len(), len);
for u in [0.1, 0.25, 0.5, 0.75, 0.99] {
let n = (u * len as f64) as usize;
near(fast[0][n], 1.0 / 3.0 + 2.0 / 3.0 * speed_up_pos(u), &format!("speed up at {u}"));
}
assert!(step(&fast[0], 200) < 0.02, "speed up starts near a stop: {}", step(&fast[0], 200));
assert!((step(&fast[0], len - 100) - 1.0).abs() < 0.01, "speed up ends at normal speed: {}", step(&fast[0], len - 100));
}
#[test]
fn speed_ramp_extend_plays_everything_over_150_percent() {
let len = 30_000;
let x = vec![ramp(len), ramp(len)];
for change in [SpeedRampChange::SlowDown, SpeedRampChange::SpeedUp] {
let out = offline::speed_ramp(&x, change, SpeedRampSelection::Extend, false);
assert_eq!(out.len(), 2);
assert_eq!(out[0].len(), 45_000, "{change:?}");
assert_eq!(out[0], out[1], "{change:?}: both channels are read at the same positions");
for u in [0.1, 0.25, 0.5, 0.75, 0.97] {
let n = (u * 45_000.0) as usize;
let pos = if change == SpeedRampChange::SlowDown { slow_down_pos(u) } else { speed_up_pos(u) };
near(out[0][n], pos, &format!("{change:?} at {u}"));
}
}
}
#[test]
fn speed_ramp_fades_follow_the_speed() {
let len = 30_000;
let x = vec![vec![0.5; len]];
let slow = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, true);
let fast = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, true);
for u in [0.25, 0.5, 0.75] {
let n = (u * len as f64) as usize;
near(slow[0][n], 0.5 * (1.0 - u * u), &format!("slow down fades out at {u}"));
near(fast[0][n], 0.5 * (1.0 - (1.0 - u) * (1.0 - u)), &format!("speed up fades in at {u}"));
}
assert!(slow[0][len - 1].abs() < 1e-3 && fast[0][0].abs() < 1e-3, "the fades reach silence at the stop");
// Fades Off keeps the level.
let off = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, false);
for u in [0.25, 0.5, 0.75, 0.9] {
near(off[0][(u * len as f64) as usize], 0.5, &format!("slow down without fades at {u}"));
}
// ... at the stop too, where the reads reach either end of the input.
let up = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, false);
near(up[0][0], 0.5, "speed up without fades from the stop");
near(off[0][len - 1], 0.5, "slow down without fades at the stop");
let off = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
near(off[0][off[0].len() - 1], 0.5, "slow down with Extend and without fades at the stop");
}
#[test]
fn speed_ramp_pitch_falls_as_the_tape_slows() {
let sr = 48_000.0;
let len = 96_000;
let x = vec![(0..len).map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr).sin() * 0.5).collect::<Vec<f32>>()];
let out = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
// Upward zero crossings per second in a 0.1 s window starting at `u`.
let rate = |u: f64| {
let a = (u * out[0].len() as f64) as usize;
let w = &out[0][a..a + 4800];
w.windows(2).filter(|p| p[0] < 0.0 && p[1] >= 0.0).count() as f64 * 10.0
};
assert!((rate(0.0) - 1000.0).abs() < 20.0, "starts at the original pitch: {}", rate(0.0));
let half = 1000.0 * 0.75;
assert!((rate(0.5) - half).abs() < 30.0, "three quarters speed halfway: {}", rate(0.5));
assert!(rate(0.9) < 250.0, "low near the stop: {}", rate(0.9));
}
#[test]
fn speed_ramp_handles_empty_and_uneven_channels() {
assert!(offline::speed_ramp(&[], SpeedRampChange::SlowDown, SpeedRampSelection::Extend, true).is_empty());
let out = offline::speed_ramp(&[vec![], vec![]], SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, true);
assert!(out.iter().all(Vec::is_empty));
// A shorter channel is read as silence past its end, and every channel gets the same length.
let out = offline::speed_ramp(&[vec![0.5; 300], vec![0.5; 30]], SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
assert_eq!((out[0].len(), out[1].len()), (450, 450));
assert!(out.iter().flatten().all(|v| v.is_finite()));
near(out[0][449], 0.5, "the longer channel at the stop");
assert!(out[1][200..].iter().all(|v| v.abs() < 1e-3), "the shorter channel is silent past its end");
}
+104 -6
View File
@@ -176,11 +176,30 @@ struct Autosave {
}
impl Autosave {
/// A new folder under `root` for the session called `name`.
/// A new folder under `root` for the session called `name`. Two sessions started within the
/// same millisecond get folders of their own, never a shared one.
fn create(root: &Path, name: &str) -> Result<Autosave> {
let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis();
let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id()));
let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?;
let base = format!("{} {stamp}-{}", sanitize_name(name), std::process::id());
create_folder(root).map_err(|err| io_error(root, err))?;
let mut dir = root.join(&base);
let mut n = 1;
loop {
// Not recursive, so a folder that already exists is reported and the next name tried.
#[cfg_attr(not(unix), allow(unused_mut))]
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
match builder.create(&dir) {
Ok(()) => break,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
n += 1;
dir = root.join(format!("{base}-{n}"));
}
Err(err) => return Err(io_error(&dir, err)),
}
}
let lock = std::fs::File::create(dir.join(LOCK)).map_err(|err| io_error(&dir, err))?;
lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false })
}
@@ -210,9 +229,7 @@ impl Autosave {
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()))?;
std::fs::create_dir_all(&audio_dir)
.and_then(|()| write_synced(&audio_dir.join(&name), &bytes))
.map_err(|err| io_error(&audio_dir, err))?;
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(&name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
name
}
};
@@ -395,6 +412,18 @@ pub fn discard(left: &Leftover) {
let _ = std::fs::remove_dir_all(&left.dir);
}
/// Create `dir` and its missing parents under the autosave root. On Unix every folder made here is
/// owner-only (`0o700`): autosaves and the takes of a session never saved are unsaved work, and
/// with the default mode other accounts on the machine could read them. A folder that already
/// exists keeps its mode.
pub fn create_folder(dir: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir)
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
@@ -427,6 +456,14 @@ mod tests {
dir
}
/// Moves a saved session file's mtime a second into the past. Filesystems with coarse
/// timestamps (tmpfs) can give it and an autosave written right after it the same mtime,
/// and `leftovers` only offers autosaves strictly newer than the file they came from.
fn backdate(path: &Path) {
let then = SystemTime::now() - Duration::from_secs(1);
std::fs::File::options().write(true).open(path).unwrap().set_modified(then).unwrap();
}
fn record(e: &mut Engine, level: f32) {
let before = e.session_arc();
let s = e.session_mut();
@@ -453,6 +490,7 @@ mod tests {
let mut e = Engine::default();
record(&mut e, 0.5);
crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap();
backdate(&path);
record(&mut e, -0.25);
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
a.write(e.session(), e.path.as_deref(), None).unwrap();
@@ -511,6 +549,50 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// Autosaves are unsaved work: on Unix the folders the autosaver makes are owner-only, and an
/// autosave root that already exists keeps its mode.
#[cfg(unix)]
#[test]
fn the_autosaver_makes_owner_only_folders() {
use std::os::unix::fs::PermissionsExt as _;
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
let base = folder("autosave-modes");
let mut e = Engine::default();
record(&mut e, 0.5);
// A root that doesn't exist yet is made with the first autosave, owner-only like the
// session's folder and the audio copied into it.
let new = base.join("Autosave");
let w = Autosaver::new(new.clone());
w.write(e.session_arc(), None, None);
w.flush().unwrap();
let session = std::fs::read_dir(&new).unwrap().flatten().map(|d| d.path()).next().unwrap();
assert_eq!((mode(&new), mode(&session), mode(&session.join("Audio Files"))), (0o700, 0o700, 0o700));
w.close(false);
w.flush().unwrap();
// A root that already exists keeps its mode; the folders made inside it are owner-only.
let old = base.join("old");
std::fs::create_dir_all(&old).unwrap();
std::fs::set_permissions(&old, std::fs::Permissions::from_mode(0o755)).unwrap();
let w = Autosaver::new(old.clone());
w.write(e.session_arc(), None, None);
w.flush().unwrap();
let session = std::fs::read_dir(&old).unwrap().flatten().map(|d| d.path()).next().unwrap();
assert_eq!((mode(&old), mode(&session)), (0o755, 0o700));
// A session never saved records its takes into `<name>/Audio Files` under the root. Both
// are made owner-only, and so is `Audio Files` in a take folder an earlier version left.
let takes = old.join("Untitled").join("Audio Files");
create_folder(&takes).unwrap();
assert_eq!((mode(&old.join("Untitled")), mode(&takes)), (0o700, 0o700));
let earlier = old.join("Earlier");
std::fs::create_dir_all(&earlier).unwrap();
std::fs::set_permissions(&earlier, std::fs::Permissions::from_mode(0o755)).unwrap();
create_folder(&earlier.join("Audio Files")).unwrap();
assert_eq!((mode(&earlier), mode(&earlier.join("Audio Files"))), (0o755, 0o700));
w.close(false);
w.flush().unwrap();
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_take_still_recording_at_a_crash_is_placed_on_recovery() {
use crate::io::Take;
@@ -590,6 +672,21 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sessions_started_together_get_folders_of_their_own() {
// #579: the folder name has millisecond resolution, so two sessions created back to back
// shared one folder and the second overwrote the first's unsaved work.
let dir = folder("autosave-unique");
let root = dir.join("Autosave");
let made: Vec<Autosave> = (0..20).map(|_| Autosave::create(&root, "Song").unwrap()).collect();
let mut dirs: Vec<&PathBuf> = made.iter().map(|a| &a.dir).collect();
dirs.sort();
dirs.dedup();
assert_eq!(dirs.len(), made.len());
drop(made);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unsaved_work_is_offered_once_its_session_closes() {
let dir = folder("autosave-leftovers");
@@ -598,6 +695,7 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let mut e = Engine::default();
crate::io::save_session(&mut e, &path).unwrap();
backdate(Path::new(&path));
record(&mut e, 0.5);
let mut a = Autosave::create(&root, "Song").unwrap();
a.write(e.session(), e.path.as_deref(), None).unwrap();
+107 -21
View File
@@ -50,7 +50,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
};
let n = clips.len();
lanes.sort_by_key(|(i, _)| *i);
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string();
let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Clip Group").to_string();
let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem));
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
let mut audio = BTreeMap::new();
@@ -80,26 +80,17 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
Ok(n)
}
/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks
/// are made when the session runs out). Returns the ids of the placed clips.
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> {
let id = "file.import_clip_groups";
let text = crate::io::read_text(path)?;
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?;
if file.format != FORMAT {
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
}
if file.lanes.iter().map(|l| l.len().max(1)).sum::<usize>() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
}
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
// Decode audio first so a bad file changes nothing, and each file once however many ids name it.
fn decode_group_audio(
audio_entries: &BTreeMap<u64, String>,
dir: &Path,
rate: u32,
cmd_id: &str,
) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
let rate = e.session().sample_rate.hz();
for (old, rel) in &file.audio {
for (old, rel) in audio_entries {
let full = dir.join(rel);
if !full.starts_with(&dir) || rel.contains("..") {
return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}")));
if !full.starts_with(dir) || rel.contains("..") {
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
}
let key = crate::io::file_key(&full);
let n = match index.get(&key) {
@@ -116,6 +107,25 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
};
file_of.insert(*old, n);
}
Ok((decoded, file_of))
}
/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks
/// are made when the session runs out). Returns the ids of the placed clips.
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> {
let id = "file.import_clip_groups";
let text = crate::io::read_text(path)?;
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?;
if file.format != FORMAT {
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
}
if file.lanes.iter().map(|l| l.len().max(1)).sum::<usize>() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
}
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
// Decode audio first so a bad file changes nothing, and each file once however many ids name it.
let rate = e.session().sample_rate.hz();
let (decoded, file_of) = decode_group_audio(&file.audio, &dir, rate, id)?;
let src_rate = f64::from(file.sample_rate.max(1));
let s = e.session_mut();
let ratio = f64::from(s.sample_rate.hz()) / src_rate;
@@ -123,7 +133,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
let sources: Vec<SourceId> = decoded
.into_iter()
.map(|(rel, buf)| {
let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string();
let name = Path::new(&rel).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("clip group").to_string();
crate::io::add_source(s, &name, buf, None, FileFormat::Wav)
})
.collect();
@@ -149,6 +159,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
s.add_track(kind, fmt, Some("Clip Group"))
}
};
let mut prepared = Vec::new();
for mut c in lane {
if let ClipContent::Audio { source, offset } = &mut c.content {
let Some(n) = map.get(&source.0) else { continue };
@@ -160,10 +171,25 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
c.id = s.new_clip_id();
c.start = at.saturating_add(scale(c.start.max(0)));
c.length = scale(c.length);
c.fade_in.len = scale(c.fade_in.len);
c.fade_out.len = scale(c.fade_out.len);
c.sync_point = scale(c.sync_point);
c.gain_env.iter_mut().for_each(|(at, _)| *at = scale(*at));
c.group = Some(group_id);
// The file does not hold the Auto Rename schemes of the session it came from.
c.auto_name = None;
c.sanitize();
placed.push(c.id);
crate::edit::place_clip(s, target, c);
prepared.push(c);
}
// Clear what was already on the track first, then add the whole lane at once, so clips of
// the group that overlap each other (crossfades) keep their overlap.
for c in &prepared {
crate::edit::clear_range(s, target, c.range(), false);
}
if let Some(pl) = s.track_mut(target).and_then(|t| t.playlist_mut()) {
pl.clips.extend(prepared);
pl.sort();
}
}
Ok(placed)
@@ -207,6 +233,21 @@ mod tests {
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()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("named.scgrp").to_string_lossy().to_string();
let mut e = crate::demo::demo_engine();
let kick = e.session().track_by_name("Kick").unwrap().clips()[0].id.0;
e.execute("clip.auto_rename", &json!({"clips": [kick], "name": "Hit"})).unwrap();
e.execute("file.export_clip_groups", &json!({"path": path, "clips": [kick]})).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 30_000_000, "track": "Bass"})).unwrap();
let placed = soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap());
assert_eq!(e.session().find_clip(placed).unwrap().1.auto_name, None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn positions_in_a_group_file_are_kept_within_reach() {
let dir = std::env::temp_dir().join(format!("sc-grp-far-{}", std::process::id()));
@@ -229,6 +270,51 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn overlapping_clips_of_a_group_keep_their_overlap_on_import() {
let dir = std::env::temp_dir().join(format!("sc-grp-overlap-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let p = dir.join("overlap.scgrp");
let note = json!({"pitch": 60, "velocity": 100, "release_velocity": 64, "channel": 0, "start": 0, "length": 100});
let content = json!({"Midi": {"sequence": {"notes": [note], "ctrls": []}}});
let a = json!({"id": 1, "name": "a", "start": 0, "length": 1000, "content": content});
let b = json!({"id": 2, "name": "b", "start": 600, "length": 1000, "content": content});
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 1600, "lanes": [[a, b]], "audio": {}});
std::fs::write(&p, file.to_string()).unwrap();
let mut e = crate::demo::demo_engine();
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
let ids: Vec<_> = r["clips"].as_array().unwrap().iter().filter_map(serde_json::Value::as_u64).collect();
assert_eq!(ids.len(), 2);
let spans: Vec<_> =
ids.iter().filter_map(|id| e.session().find_clip(soundcraft_model::ClipId(*id)).map(|(_, c)| (c.start, c.length))).collect();
// The group lands at the insertion point; its clips keep their relative positions and overlap.
let at = spans.first().map_or(0, |s| s.0);
assert_eq!(spans, vec![(at, 1000), (at + 600, 1000)]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fades_sync_point_and_envelope_follow_the_sample_rate() {
let dir = std::env::temp_dir().join(format!("sc-grp-rate-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let p = dir.join("rate.scgrp");
let mut e = crate::demo::demo_engine();
// The file was saved at half the session rate, so every position doubles.
let src_rate = e.session().sample_rate.hz() / 2;
let fade = |len: i64| json!({"len": len, "shape": "Linear"});
let clip = json!({
"id": 1, "name": "m", "start": 0, "length": 10_000, "content": {"Midi": {"sequence": {"notes": [], "ctrls": []}}},
"fade_in": fade(1000), "fade_out": fade(2000), "sync_point": 500, "gain_env": [[100, -6.0], [4000, 0.0]]
});
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": src_rate, "length": 10_000, "lanes": [[clip]], "audio": {}});
std::fs::write(&p, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
let c = e.session().find_clip(soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap())).unwrap().1;
assert_eq!((c.length, c.fade_in.len, c.fade_out.len, c.sync_point), (20_000, 2000, 4000, 1000));
assert_eq!(c.gain_env.iter().map(|&(at, _)| at).collect::<Vec<_>>(), vec![200, 8000]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_file_named_by_many_ids_becomes_one_source() {
let dir = std::env::temp_dir().join(format!("sc-grp-shared-{}", std::process::id()));
+3 -3
View File
@@ -1,9 +1,9 @@
//! Edit › Copy Special › Audio as MIDI: pitch-track the selected audio into MIDI notes on the
//! clipboard (paste them onto a MIDI or instrument track).
use super::{CommandSpec, bad, has_range, range_param, tracks_required};
use super::*;
use crate::{Clipboard, cmd};
use serde_json::json;
use crate::{Clipboard, Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, ClipId};
+119 -6
View File
@@ -1,9 +1,11 @@
//! AudioSuite-style offline processing of selected clips.
use super::{CommandSpec, bad, clip_ids_param, has_selection, range_param, str_param, tracks_param};
use soundcraft_time::Range;
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_dsp::offline::{SpeedRampChange, SpeedRampSelection};
use soundcraft_model::{Clip, ClipContent, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
@@ -12,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AudioSuite Process",
[],
None,
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | 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)",
"{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)",
has_selection,
process
)]
@@ -30,7 +32,16 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let known = soundcraft_dsp::plugin_info(&name).is_some()
|| matches!(
name.as_str(),
"normalize" | "reverse" | "invert" | "gain" | "duplicate" | "time_stretch" | "pitch_shift" | "varispeed" | "dc_offset_removal"
"normalize"
| "reverse"
| "invert"
| "gain"
| "duplicate"
| "time_stretch"
| "pitch_shift"
| "varispeed"
| "speed_ramp"
| "dc_offset_removal"
);
if !known {
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
@@ -49,12 +60,16 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0));
let semitones = get("semitones", 0.0).clamp(-24.0, 24.0);
let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32;
let change = if get("change", 0.0) >= 0.5 { SpeedRampChange::SpeedUp } else { SpeedRampChange::SlowDown };
let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo };
let fades = get("fades", 1.0) >= 0.5;
// These processes cut their output at MAX_OFFLINE_SAMPLES frames.
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
let most = match name.as_str() {
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr.as_f64()),
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
_ => usize::MAX,
};
let mut done = 0;
@@ -100,6 +115,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
"time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, ratio, srf),
"pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, semitones, srf),
"varispeed" => soundcraft_dsp::offline::resample(&audio, sr.hz(), to),
"speed_ramp" => soundcraft_dsp::offline::speed_ramp(&audio, change, selection, fades),
plugin => {
apply(plugin, &params, &mut audio, srf)?;
audio
@@ -117,6 +133,8 @@ 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);
// 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,
// the same way its fades stay editable. Other processes bake a new file at unity gain.
if !matches!(name.as_str(), "invert" | "duplicate") {
@@ -137,9 +155,17 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result<Value> {
let sr = e.session().sample_rate;
let tracks = tracks_param(e, "audiosuite.process", p)?;
let mut budget = soundcraft_mix::MAX_RENDER_SAMPLES;
// Refuse before generating anything, rather than cut the clip short.
for t in tracks.iter().filter_map(|t| e.session().track(*t)).filter(|t| t.kind == TrackKind::Audio) {
crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(t.channels()))?;
let channels = t.channels().max(1);
crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(channels))?;
// Each track keeps its generated buffer for the life of the command (and undo holds it), so
// cap the total samples generated across tracks, not just each one.
let frames = usize::try_from(r.len()).unwrap_or(0).min(crate::io::render_limit(channels));
budget = budget
.checked_sub(frames.saturating_mul(channels))
.ok_or_else(|| bad("audiosuite.process", "too much audio to generate in one command"))?;
}
let mut clips = Vec::new();
for track in tracks {
@@ -216,6 +242,54 @@ mod tests {
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
}
#[test]
fn processed_clip_does_not_keep_the_stretch_it_baked() {
for stretch in [0.5, 2.0] {
for process in ["invert", "duplicate"] {
let (mut e, id) = gained_clip();
e.session_mut().find_clip_mut(id).unwrap().stretch = stretch;
e.execute("audiosuite.process", &json!({"process": process, "clips": [id.0]})).unwrap();
let c = e.session().tracks[0].clips()[0].clone();
let frames = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0].len();
assert_eq!(c.stretch, 1.0, "{process} at {stretch}");
assert_eq!(c.length, i64::try_from(frames).unwrap(), "{process} at {stretch}");
}
}
}
#[test]
fn speed_ramp_ramps_the_speed_and_extend_lengthens_the_clip() {
// A mono clip at 480 whose samples rise from 0 to 1, so each output sample shows where it was read.
let run = |params: Value| {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Ramp"));
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![(0..4800).map(|i| i as f32 / 4800.0).collect()] };
let src = crate::io::add_source(&mut s, "ramp", buf, None, FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(id, "ramp", src, 0, 480, 4800));
let mut e = crate::Engine::new(s);
e.execute("audiosuite.process", &json!({"process": "speed_ramp", "params": params, "clips": [id.0]})).unwrap();
let c = e.session().tracks[0].clips()[0].clone();
let audio = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0].clone();
(c.start, c.length, c.name, audio)
};
// The defaults: Slow Down, Fit To, Fades On. Halfway through, the read position is 11/16 of the
// way through the first two thirds, and the gain is the speed there, 3/4.
let (start, length, name, audio) = run(json!({}));
assert_eq!((start, length, name.as_str()), (480, 4800, "ramp-speeramp"));
let want = (2.0 / 3.0 * 11.0 / 16.0) * 0.75;
assert!((audio[2400] - want).abs() < 2e-3, "slow down halfway: got {}, want {want}", audio[2400]);
assert!(audio[4790].abs() < 0.01, "the fade-out ends near silence, got {}", audio[4790]);
// Speed Up with Extend and no fades: all of the clip is played over 150 % of its length, and the
// clip still starts at 480.
let (start, length, _, audio) = run(json!({"change": 1, "selection": 1, "fades": 0}));
assert_eq!((start, length), (480, 7200));
assert!((audio[3600] - 5.0 / 16.0).abs() < 2e-3, "speed up halfway: got {}", audio[3600]);
let r = 1.0 - 7100.0 / 7200.0;
let want = 1.0 - (3.0 * r - r * r * r) / 2.0;
assert!((audio[7100] - want).abs() < 2e-3, "speed up near the end: got {}, want {want}", audio[7100]);
}
#[test]
fn signal_generator_fills_the_selection_with_a_clip() {
let (mut e, _) = gained_clip();
@@ -239,4 +313,43 @@ mod tests {
let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect();
assert_eq!(ranges, vec![(0, 32), (32, 64)]);
}
#[test]
fn duplicate_track_ids_generate_once_not_per_listing() {
let mut e = Engine::new(Session::default());
let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 4800})).unwrap();
// The same track eight times over: it must generate once, not eight times (#896).
let out = e
.execute(
"audiosuite.process",
&json!({"process": "signal_generator", "tracks": [t.0, t.0, t.0, t.0, t.0, t.0, t.0, t.0], "params": {"freq": 440.0}}),
)
.unwrap();
assert_eq!(out["processed"], 1);
assert_eq!(e.session().tracks[0].clips().len(), 1, "one clip, not one per duplicate listing");
}
#[test]
fn generate_refuses_a_total_budget_across_tracks() {
let mut e = Engine::new(Session::default());
// Two tracks each near the per-track limit: the second would push the total past the budget.
for n in 0..2 {
e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&format!("T{n}")));
}
let t: Vec<_> = e.session().tracks.iter().map(|t| t.id.0).collect();
// Select both tracks so signal_generator targets them.
e.execute("edit.select", &json!({"tracks": [t[0], t[1]], "start": 0, "end": 4800})).unwrap();
e.execute("audiosuite.process", &serde_json::json!({"process": "signal_generator", "track": t[0], "params": {"freq": 440.0}})).unwrap();
assert_eq!(e.session().tracks[0].clips().len(), 1, "one track generates one clip");
// Two distinct tracks over the total budget are refused (MAX_RENDER_SAMPLES is f32 samples
// across all channels; a single track over the limit is already refused per-track).
let len = (soundcraft_mix::MAX_RENDER_SAMPLES as i64) / 3 * 2;
let out = e.execute(
"audiosuite.process",
&serde_json::json!({"process": "signal_generator", "tracks": [t[0], t[1]], "params": {"freq": 440.0}, "start": 0, "end": len}),
);
let ok = matches!(out, Err(crate::EngineError::BadParams(_, ref m)) if m.contains("too much audio"));
assert!(ok, "two tracks over budget, but got: {out:?}");
}
}
+229
View File
@@ -0,0 +1,229 @@
//! Clip List › Auto Rename: the names that editing gives the clips it separates from a clip.
use super::{CommandSpec, bad};
use crate::{Engine, Result, cmd};
use serde::Deserialize;
use serde_json::{Value, json};
use soundcraft_model::ClipId;
use std::collections::BTreeSet;
const AUTO_RENAME: &str = "clip.auto_rename";
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"clip.auto_rename",
"Auto Rename...",
[],
None,
"{clips?: [id], name?, number?: 0..999999999, zeros?: 0..9, suffix?}: sets the names that editing gives the clips it separates from these clips (default: the Clip List's selected clips): the piece after each cut is named `name`, then `zeros` zeros, then a number counting up from `number` (default 1), then `suffix`, in one number sequence for all of them. Without a name, the clips keep their names when they are separated",
has_clip_selection,
auto_rename
)]
}
fn has_clip_selection(e: &Engine) -> std::result::Result<(), String> {
if e.session().edit.selected_clips.is_empty() { Err("select a clip in the Clip List".into()) } else { Ok(()) }
}
fn auto_rename(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session();
let clips: BTreeSet<ClipId> = match p.get("clips") {
None => s.edit.selected_clips.iter().copied().collect(),
Some(v) => BTreeSet::deserialize(v).map_err(|_| bad(AUTO_RENAME, "`clips` must be an array of ids"))?,
};
if clips.is_empty() {
return Err(bad(AUTO_RENAME, "select a clip in the Clip List, or pass `clips`"));
}
let known: BTreeSet<ClipId> = s.all_clips().map(|c| c.id).collect();
if let Some(c) = clips.iter().find(|c| !known.contains(c)) {
return Err(bad(AUTO_RENAME, format!("no clip with id {c}")));
}
let names = super::edit::strip_names(AUTO_RENAME, p)?;
let s = e.session_mut();
let key = names.map(|n| {
let (key, id) = (s.alloc(), crate::new_scheme_id(s));
s.auto_names.insert(key, soundcraft_model::AutoName { id, ..n.into() });
key
});
for c in s.tracks.iter_mut().flat_map(|t| t.playlists.iter_mut()).flat_map(|pl| pl.clips.iter_mut()) {
if clips.contains(&c.id) {
c.auto_name = key;
}
}
// Earlier schemes stay while the session is open: copies on the clipboard can still use them.
Ok(json!({"clips": clips.len(), "named": key.is_some()}))
}
#[cfg(test)]
mod tests {
use super::*;
/// The first Kick clip: its track, id, start and length.
fn first_kick(e: &Engine) -> (soundcraft_model::TrackId, ClipId, i64, i64) {
let s = e.session();
let t = s.track_by_name("Kick").unwrap();
let c = &t.clips()[0];
(t.id, c.id, c.start, c.length)
}
/// The names of the clips on `t` that lie inside `[start, end)`, in time order.
fn names_in(e: &Engine, t: soundcraft_model::TrackId, start: i64, end: i64) -> Vec<String> {
e.session().track(t).unwrap().clips().iter().filter(|c| c.start >= start && c.end() <= end).map(|c| c.name.clone()).collect()
}
#[test]
fn separating_an_auto_renamed_clip_names_each_new_piece() {
let mut e = crate::demo::demo_engine();
let (t, id, start, len) = first_kick(&e);
let old = e.session().find_clip(id).unwrap().1.name.clone();
let r = e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX", "number": 23, "zeros": 1, "suffix": ".Reel1"})).unwrap();
assert_eq!(r, json!({"clips": 1, "named": true}));
assert_eq!(e.undo_label(), Some("Auto Rename..."));
assert_eq!(names_in(&e, t, start, start + len), [old.as_str()], "setting the scheme leaves the clip's name");
// Cut the middle, then each half: every piece after a cut is a new clip with the next name.
for at in [start + len / 2, start + len / 4, start + len * 3 / 4] {
e.execute("edit.separate", &json!({"tracks": [t], "at": at})).unwrap();
}
assert_eq!(names_in(&e, t, start, start + len), [old.as_str(), "SFX024.Reel1", "SFX023.Reel1", "SFX025.Reel1"]);
assert!(e.undo());
assert_eq!(names_in(&e, t, start, start + len).len(), 3);
assert_eq!(e.session().auto_names.values().next().map(|a| a.number), Some(25), "undo takes the number back");
}
#[test]
fn auto_rename_uses_the_clip_list_selection_and_one_number_sequence() {
let mut e = crate::demo::demo_engine();
let s = e.session();
let t = s.track_by_name("Kick").unwrap();
let (track, a, b) = (t.id, t.clips()[0].clone(), t.clips()[1].clone());
e.execute("edit.select", &json!({"clips": [a.id, b.id]})).unwrap();
e.execute("clip.auto_rename", &json!({"name": "Hit"})).unwrap();
e.execute("edit.separate", &json!({"tracks": [track], "at": b.start + b.length / 2})).unwrap();
e.execute("edit.separate", &json!({"tracks": [track], "at": a.start + a.length / 2})).unwrap();
assert_eq!(names_in(&e, track, b.start, b.end())[1], "Hit1");
assert_eq!(names_in(&e, track, a.start, a.end())[1], "Hit2", "both clips count up one sequence");
assert_eq!(e.session().auto_names.len(), 1);
}
#[test]
fn auto_rename_without_a_name_removes_the_scheme() {
let mut e = crate::demo::demo_engine();
let (t, id, start, len) = first_kick(&e);
let old = e.session().find_clip(id).unwrap().1.name.clone();
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
let r = e.execute("clip.auto_rename", &json!({"clips": [id], "name": " "})).unwrap();
assert_eq!(r["named"], false);
assert_eq!(e.session().find_clip(id).unwrap().1.auto_name, None);
e.execute("edit.separate", &json!({"tracks": [t], "at": start + len / 2})).unwrap();
assert_eq!(names_in(&e, t, start, start + len), [old.clone(), old]);
}
#[test]
fn a_pasted_copy_keeps_the_scheme_it_was_copied_with() {
let mut e = crate::demo::demo_engine();
let (t, id, start, len) = first_kick(&e);
let old = e.session().find_clip(id).unwrap().1.name.clone();
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap();
e.execute("clip.auto_rename", &json!({"clips": [id]})).unwrap();
let at = e.session().content_end() + 48_000;
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap();
assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1", "the copy's scheme outlives the original clip's");
// Pasted into another session, the copy brings its scheme along and counts on from it.
e.execute("edit.copy", &json!({"tracks": [t], "start": at, "end": at + len})).unwrap();
e.replace_session(crate::demo::demo_engine().session().clone());
let t = first_kick(&e).0;
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 4})).unwrap();
assert_eq!(names_in(&e, t, at, at + len), [old.as_str(), "SFX2", "SFX1"]);
}
#[test]
fn a_copy_keeps_its_scheme_when_undo_hands_the_key_to_another() {
let mut e = crate::demo::demo_engine();
let (t, id, start, len) = first_kick(&e);
let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id;
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap();
assert!(e.undo());
// The same name under the key the undo freed is still another scheme.
e.execute("clip.auto_rename", &json!({"clips": [bass], "name": "SFX", "number": 100})).unwrap();
let at = e.session().content_end() + 48_000;
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap();
assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1");
let s = e.session();
assert_eq!(s.auto_names.len(), 2);
let key = s.find_clip(bass).unwrap().1.auto_name.unwrap();
assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched");
}
#[test]
fn a_copy_keeps_its_scheme_when_another_session_holds_its_id_with_other_text() {
let mut e = crate::demo::demo_engine();
let (t, id, start, len) = first_kick(&e);
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
let copied = e.session().auto_names.values().next().unwrap().id;
e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap();
// Another session holds a scheme under the same id with another name.
e.replace_session(crate::demo::demo_engine().session().clone());
let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id;
let s = e.session_mut();
let key = s.alloc();
s.auto_names.insert(key, soundcraft_model::AutoName { name: "Other".into(), number: 100, zeros: 0, suffix: String::new(), id: copied });
s.tracks
.iter_mut()
.flat_map(|t| t.playlists.iter_mut())
.flat_map(|pl| pl.clips.iter_mut())
.filter(|c| c.id == bass)
.for_each(|c| c.auto_name = Some(key));
let t = first_kick(&e).0;
let at = e.session().content_end() + 48_000;
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap();
assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1");
let s = e.session();
assert_eq!(s.auto_names.len(), 2);
assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched");
}
#[test]
fn bad_auto_rename_params_are_refused() {
let mut e = crate::demo::demo_engine();
let (_, id, _, _) = first_kick(&e);
for p in [
json!({"name": "SFX"}),
json!({"clips": [], "name": "SFX"}),
json!({"clips": [999_999], "name": "SFX"}),
json!({"clips": "Kick", "name": "SFX"}),
json!({"clips": [id], "name": 5}),
json!({"clips": [id], "name": "SFX", "zeros": 10}),
json!({"clips": [id], "name": "SFX", "number": 1_000_000_000}),
json!({"clips": [id], "number": 3}),
] {
assert!(e.execute("clip.auto_rename", &p).is_err(), "{p}");
}
assert!(e.session().auto_names.is_empty());
assert!(e.session().find_clip(id).unwrap().1.auto_name.is_none());
}
#[test]
fn auto_rename_schemes_are_saved_and_repaired_on_open() {
let mut e = crate::demo::demo_engine();
let (t, id, start, len) = first_kick(&e);
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX", "number": 7})).unwrap();
let mut v: Value = serde_json::from_str(&e.session().to_json().unwrap()).unwrap();
let key = *e.session().auto_names.keys().next().unwrap();
v["auto_names"][key.to_string()]["zeros"] = json!(usize::MAX);
v["auto_names"]["999999999"] = json!({"name": "Unused", "number": 1, "zeros": 0, "suffix": ""});
let mut s = soundcraft_model::Session::from_json(&v.to_string()).unwrap();
assert_eq!(s.auto_names.len(), 1, "a scheme no clip uses is dropped");
assert_eq!(s.auto_names[&key].zeros, soundcraft_model::AutoName::MAX_ZEROS);
assert!(s.next_id > key);
crate::edit::separate_at(&mut s, t, start + len / 2);
let names: Vec<String> =
s.track(t).unwrap().clips().iter().filter(|c| c.start >= start && c.end() <= start + len).map(|c| c.name.clone()).collect();
assert_eq!(names[1], "SFX0000000007");
}
}
+2 -2
View File
@@ -1,9 +1,9 @@
//! Third-party CLAP plugins: discovery (`engine.clap_plugins`) and id lookup shared by the mixer
//! commands. Hosting itself lives in `soundcraft-clap-host`.
use super::{CommandSpec, always};
use super::*;
use crate::cmd;
use serde_json::json;
use serde_json::{Value, json};
use soundcraft_dsp::PluginInfo;
pub fn specs() -> Vec<CommandSpec> {
+146 -6
View File
@@ -1,8 +1,10 @@
//! Clip menu commands.
use super::{CommandSpec, always, bad, clip_ids_param, f64_or, has_range, has_selection, i64_or, position_param, str_param, track_param};
use soundcraft_model::ClipId;
use soundcraft_time::Samples;
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -65,7 +67,15 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(json!({}))
}),
cmd!("clip.loop", "Loop...", ["Clip"], Some("Cmd+Alt+L"), "{clips?, count?: n, length?: samples}", has_selection, loop_clips),
cmd!(
"clip.loop",
"Loop...",
["Clip"],
Some("Cmd+Alt+L"),
"{clips?, count?: n, length?: samples, fill?: bool}: count is the clip plus its loops; length is the whole looped clip, with the last loop cut short to fit; fill loops up to the next clip on the track, or else to the session end",
has_selection,
loop_clips
),
cmd!("clip.rename", "Rename...", ["Clip"], Some("Cmd+Alt+Shift+R"), "{clip?, name}", has_selection, rename),
cmd!("clip.gain", "Clip Gain", [], None, "{clips?, db | delta_db}", has_selection, gain),
cmd!("clip.gain_nudge_up", "Nudge Clip Gain Up", [], Some("Ctrl+Shift+Up"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, 1.0)),
@@ -102,6 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(json!({}))
}),
cmd!("clip.remove_sync_point", "Remove Sync Point", ["Clip"], None, "{clips?}", has_sync_point, remove_sync_point),
cmd!("clip.quantize_to_grid", "Quantize to Grid", ["Clip"], Some("Cmd+0"), "{clips?}", has_selection, quantize_to_grid),
cmd!("clip.color", "Clip Color", [], None, "{clips?, color: [r,g,b] | null}", has_selection, |e, p| {
let ids = clip_ids_param(e, p);
@@ -284,15 +295,46 @@ fn layer(e: &mut Engine, p: &Value, front: bool) -> Result<Value> {
Ok(json!({}))
}
/// The most loops `clip.loop` makes of one clip.
const MAX_LOOPS: i64 = 1000;
fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let count = i64_or(p, "count", 4).clamp(1, 512);
let total = position_param(e, "clip.loop", p, "length")?;
let fill = p.get("fill").and_then(Value::as_bool).unwrap_or(false);
let s = e.session_mut();
let session_end = s.content_end();
let mut made = 0;
for id in ids {
let Some((tid, mut c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1).clamp(0, 1000);
if c.length <= 0 {
continue;
}
let len = c.length;
// Where the looped clip ends: the next clip or the session end, or `length` after the start.
let end = if fill {
let next = s.track(tid).and_then(|t| t.clips().iter().filter(|x| x.id != c.id && x.start >= c.end()).map(|x| x.start).min());
Some(next.unwrap_or(session_end))
} else {
total.map(|l| c.start.saturating_add(l))
};
// Whole iterations, then one cut short to reach `end` exactly.
let (reps, last) = match end {
Some(end) => {
let span = end.saturating_sub(c.end()).max(0);
let (whole, rest) = (span / len, span % len);
let reps = whole.saturating_add(i64::from(rest > 0));
if reps > MAX_LOOPS {
return Err(bad(
"clip.loop",
format!("“{}” would need {reps} loops to fill that; a clip loops at most {MAX_LOOPS} times", c.name),
));
}
(reps, if rest > 0 { rest } else { len })
}
None => ((count - 1).clamp(0, MAX_LOOPS), len),
};
if reps > 0 && c.loop_id.is_none() {
let lid = s.alloc();
if let Some(src) = s.find_clip_mut(id) {
@@ -304,10 +346,15 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
for k in 1..=reps {
let mut nc = c.clone();
nc.start = 0;
if k == reps && last < c.length {
// `start` is 0 here, so the end is the length; the trim also shortens the fades to fit and removes a sync point past the cut.
nc.trim_end_to(last);
}
copies.push((c.start + c.length * k, nc));
}
for (at, nc) in copies {
let _ = crate::edit::paste(s, tid, at, &[nc], c.length, false);
let length = nc.length;
let _ = crate::edit::paste(s, tid, at, &[nc], length, false);
made += 1;
}
}
@@ -348,6 +395,31 @@ fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result<Value> {
gain(e, &json!({"clips": clips, "delta_db": db}))
}
/// Sync point 0 is "none" (edit_window only draws one when > 0).
fn has_sync_point(e: &Engine) -> std::result::Result<(), String> {
let s = e.session();
if clip_ids_param(e, &json!({})).iter().any(|id| s.find_clip(*id).is_some_and(|(_, c)| c.sync_point != 0)) {
Ok(())
} else {
Err("no selected clip has a sync point".into())
}
}
fn remove_sync_point(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session();
// Only clips that have a point are touched, so a call that changes nothing leaves no undo step.
let ids: Vec<ClipId> = clip_ids_param(e, p).into_iter().filter(|id| s.find_clip(*id).is_some_and(|(_, c)| c.sync_point != 0)).collect();
if !ids.is_empty() {
let s = e.session_mut();
for id in &ids {
if let Some(c) = s.find_clip_mut(*id) {
c.sync_point = 0
}
}
}
Ok(json!({"clips": ids.len()}))
}
fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session();
@@ -452,4 +524,72 @@ mod tests {
e.execute("clip.gain_nudge_up", &json!({})).unwrap();
assert_eq!(gain(&e), 36.0);
}
/// Three 10 000-sample clips side by side on one track; sync points are set by each test.
fn three_clips() -> (crate::Engine, [ClipId; 3]) {
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Dialog"));
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10_000]] };
let src = crate::io::add_source(&mut s, "Line", buf, None, soundcraft_audio_io::FileFormat::Wav);
let ids = [0, 1, 2].map(|k| {
let cid = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Line", src, 0, k * 12_000, 10_000));
cid
});
(crate::Engine::new(s), ids)
}
fn sync_of(e: &crate::Engine, id: ClipId) -> i64 {
e.session().find_clip(id).unwrap().1.sync_point
}
#[test]
fn remove_sync_point_clears_the_point_identify_set() {
let (mut e, [a, ..]) = three_clips();
e.execute("edit.select", &json!({"clips": [a.0]})).unwrap();
e.execute("clip.identify_sync_point", &json!({"at": 4_000})).unwrap();
assert_eq!(sync_of(&e, a), 4_000);
e.execute("clip.remove_sync_point", &json!({})).unwrap();
assert_eq!(sync_of(&e, a), 0);
}
#[test]
fn remove_sync_point_clears_every_selected_clip_and_only_those() {
let (mut e, [a, b, c]) = three_clips();
for (id, at) in [(a, 1_000), (b, 2_000), (c, 3_000)] {
e.session_mut().find_clip_mut(id).unwrap().sync_point = at;
}
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
let r = e.execute("clip.remove_sync_point", &json!({})).unwrap();
assert_eq!(r["clips"], 2);
assert_eq!([sync_of(&e, a), sync_of(&e, b), sync_of(&e, c)], [0, 0, 3_000]);
}
#[test]
fn remove_sync_point_is_disabled_unless_a_selected_clip_has_one() {
let (mut e, [a, b, c]) = three_clips();
e.session_mut().find_clip_mut(c).unwrap().sync_point = 3_000;
let enabled = |e: &crate::Engine| crate::find_command("clip.remove_sync_point").unwrap().info(e).enabled;
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
assert!(!enabled(&e), "neither selected clip has a sync point (the unselected one does)");
e.execute("edit.select", &json!({"clips": [b.0, c.0]})).unwrap();
assert!(enabled(&e));
}
#[test]
fn remove_sync_point_is_one_undo_step_that_puts_the_points_back() {
let (mut e, [a, b, _]) = three_clips();
for (id, at) in [(a, 1_000), (b, 2_000)] {
e.session_mut().find_clip_mut(id).unwrap().sync_point = at;
}
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
let steps = e.undo_history().len();
e.execute("clip.remove_sync_point", &json!({})).unwrap();
assert_eq!(e.undo_history().len(), steps + 1);
assert_eq!(e.undo_label(), Some("Remove Sync Point"));
assert_eq!([sync_of(&e, a), sync_of(&e, b)], [0, 0]);
assert!(e.undo());
assert_eq!([sync_of(&e, a), sync_of(&e, b)], [1_000, 2_000]);
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
//! Clip List commands.
use super::{CommandSpec, bad, bool_or};
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::SourceId;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{ClipId, SourceId};
use std::collections::BTreeSet;
const CLEAR: &str = "clip.clear";
+128 -14
View File
@@ -1,10 +1,10 @@
//! More Clip menu commands: Regroup, Unloop, Clip Effects bypass/render, Conform to Tempo and
//! Remove Pitch Shift.
use super::{CommandSpec, bad, clip_ids_param, has_selection, str_param};
use super::more_util::{render_stretched, replace_clip};
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{Clip, ClipContent, ClipId, Session, TrackId};
use soundcraft_time::Samples;
@@ -99,24 +99,40 @@ fn regroup(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"group": g, "clips": n}))
}
fn same_material(a: &Clip, b: &Clip) -> bool {
a.length == b.length
&& (a.loop_id.is_some() && a.loop_id == b.loop_id
|| match (&a.content, &b.content) {
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
_ => false,
})
fn same_content(a: &Clip, b: &Clip) -> bool {
match (&a.content, &b.content) {
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
_ => false,
}
}
/// The loop a clip belongs to: back-to-back copies of the same material. Origin first.
fn same_material(a: &Clip, b: &Clip) -> bool {
a.length == b.length && (a.loop_id.is_some() && a.loop_id == b.loop_id || same_content(a, b))
}
/// Whether `short` is the last iteration of `full`'s loop, cut short at its end. It shares the loop,
/// it is shorter, and no clip of the loop starts where it ends, as the other half of a split clip
/// does. When `full` is a whole copy (an iteration of the same length ends where it starts), that is
/// enough. When `full` is the loop's origin alone, `short` also has to hold `full`'s material.
fn cut_short(clips: &[Clip], full: &Clip, short: &Clip) -> bool {
let whole_copy = || clips.iter().any(|x| x.id != full.id && x.end() == full.start && same_material(x, full));
full.loop_id.is_some()
&& full.loop_id == short.loop_id
&& short.length < full.length
&& !clips.iter().any(|x| x.loop_id == short.loop_id && x.start == short.end())
&& (whole_copy() || same_content(full, short))
}
/// The loop a clip belongs to: back-to-back copies of the same material, the last of which can be
/// cut short. Origin first.
fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec<ClipId> {
let Some(tr) = s.track(t) else { return Vec::new() };
let clips = tr.clips();
let Some(mut cur) = clips.iter().find(|c| c.id == id) else { return Vec::new() };
let mut guard = 0;
while let Some(prev) = clips.iter().find(|x| x.id != cur.id && x.end() == cur.start && same_material(x, cur)) {
while let Some(prev) = clips.iter().find(|x| x.id != cur.id && x.end() == cur.start && (same_material(x, cur) || cut_short(clips, x, cur))) {
cur = prev;
guard += 1;
if guard > clips.len() {
@@ -128,6 +144,9 @@ fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec<ClipId> {
chain.push(next.id);
cur = next;
}
if let Some(last) = clips.iter().find(|x| !chain.contains(&x.id) && x.start == cur.end() && cut_short(clips, cur, x)) {
chain.push(last.id);
}
chain
}
@@ -342,6 +361,101 @@ mod tests {
assert!(e.session().track(t).unwrap().clips().iter().all(|c| c.loop_id.is_none() && c.group.is_none()));
}
fn spans(e: &Engine, t: TrackId) -> Vec<(Samples, Samples)> {
e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length)).collect()
}
#[test]
fn loop_length_cuts_the_last_iteration_short() {
let (mut e, t, id) = midi_engine();
assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap()["loops"], 2);
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 50)]);
// A length that is a whole number of loops leaves no short one.
let (mut e, t, id) = midi_engine();
assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "length": 300})).unwrap()["loops"], 2);
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100)]);
}
#[test]
fn a_short_last_iteration_fits_its_fades_and_drops_a_sync_point_past_the_cut() {
let (mut e, t, id) = midi_engine();
if let Some(c) = e.session_mut().find_clip_mut(id) {
c.sync_point = 80;
c.fade_out.len = 70;
}
e.execute("clip.loop", &json!({"clip": id.0, "length": 150})).unwrap();
let clips = e.session().track(t).unwrap().clips();
let last = clips.last().unwrap();
assert_eq!((last.start, last.length, last.sync_point, last.fade_out.len), (100, 50, 0, 50));
assert_eq!((clips[0].sync_point, clips[0].fade_out.len), (80, 70));
}
#[test]
fn unloop_removes_a_short_last_iteration_too() {
let (mut e, t, id) = midi_engine();
e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap();
e.execute("clip.unloop", &json!({"clip": id.0})).unwrap();
assert_eq!(spans(&e, t), vec![(0, 100)]);
// An edit to the source note or to the short loop's note.
for edited in [0, 2] {
let (mut e, t, id) = midi_engine();
e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap();
let target = e.session().track(t).unwrap().clips()[edited].id;
if let Some(c) = e.session_mut().find_clip_mut(target)
&& let ClipContent::Midi { sequence } = &mut c.content
{
sequence.notes[0].length = 1100;
}
e.execute("clip.unloop", &json!({"clip": id.0})).unwrap();
assert_eq!(spans(&e, t), vec![(0, 100)], "edited loop {edited}");
}
}
#[test]
fn unloop_keeps_both_halves_of_a_split_clip() {
let (mut e, t, ids) = engine_with(&[(0, 100, 0)]);
e.execute("clip.loop", &json!({"clip": ids[0].0, "count": 3})).unwrap();
crate::edit::separate_at(e.session_mut(), t, 230);
e.execute("clip.unloop", &json!({"clip": ids[0].0})).unwrap();
assert_eq!(spans(&e, t), vec![(0, 100), (200, 30), (230, 70)]);
// A split loop origin is not a loop.
let (mut e, t, ids) = engine_with(&[(0, 100, 0)]);
e.execute("clip.loop", &json!({"clip": ids[0].0, "count": 2})).unwrap();
crate::edit::separate_at(e.session_mut(), t, 30);
assert!(e.execute("clip.unloop", &json!({"clip": ids[0].0})).is_err());
assert_eq!(spans(&e, t), vec![(0, 30), (30, 70), (100, 100)]);
}
#[test]
fn loop_fill_stops_at_the_next_clip() {
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (430, 100, 0)]);
assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap()["loops"], 4);
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100), (300, 100), (400, 30), (430, 100)]);
}
#[test]
fn loop_fill_without_a_next_clip_runs_to_the_session_end() {
let (mut e, t, ids) = engine_with(&[(0, 100, 0)]);
let other = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let far = e.session_mut().new_clip_id();
crate::edit::place_clip(e.session_mut(), other, Clip::audio(far, "far", SourceId(9), 0, 200, 150));
assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap()["loops"], 3);
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100), (300, 50)]);
// The last clip in the session has nothing to fill up to.
assert_eq!(e.execute("clip.loop", &json!({"clip": far.0, "fill": true})).unwrap()["loops"], 0);
}
#[test]
fn a_fill_or_length_past_the_loop_limit_is_refused_without_an_edit() {
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100_150, 100, 0)]);
let err = e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap_err().to_string();
assert!(err.contains("1001 loops"), "{err}");
assert!(e.execute("clip.loop", &json!({"clip": ids[0].0, "length": 100_200})).is_err());
assert_eq!(spans(&e, t), vec![(0, 100), (100_150, 100)]);
// 1000 loops still fit.
assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "length": 100_100})).unwrap()["loops"], 1000);
}
#[test]
fn regroup_reuses_group() {
let (mut e, _, ids) = engine_with(&[(0, 100, 0), (500, 100, 0), (900, 100, 0)]);
+123 -19
View File
@@ -1,11 +1,15 @@
//! 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,
};
use soundcraft_time::Samples;
use super::more_util::toggle_flag;
use super::*;
use crate::edit as ops;
use crate::{Clipboard, cmd};
use serde_json::json;
use soundcraft_model::{EditMode, Fade, FadeShape, Session};
use crate::{Clipboard, Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{ClipId, EditMode, Fade, FadeShape, Session, TrackId};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -117,10 +121,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Strip Silence",
["Edit"],
Some("Cmd+U"),
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20}",
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, mode?: strip|extract|separate, name?, number?: 1, zeros?: 0, suffix?} (strip removes the silence; extract removes the audio and keeps the silence; separate separates the clips at the edges of the silence and does not remove any; with a name, the clips left in the selection are named name, zeros, a number counting up from number, suffix)",
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...",
@@ -276,6 +289,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
.unwrap_or_default();
cb.automation.push(lanes);
}
cb.keep_auto_names(e.session());
cb.markers = e
.session()
.markers
@@ -312,10 +326,21 @@ fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, f
ops::clear_range(s, t, r, sh);
}
/// The clipboard for a paste, with the Auto Rename schemes of its clips put into the session first
/// ([`Clipboard::adopt_auto_names`]).
pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
let mut cb = e.clipboard.clone();
if cb.tracks.iter().flatten().any(|c| c.auto_name.is_some()) {
cb.adopt_auto_names(e.session_mut());
e.clipboard = cb.clone();
}
cb
}
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")?;
let cb = e.clipboard.clone();
let cb = clipboard_for_paste(e);
let s = e.session();
let at = match given {
Some(at) => at,
@@ -413,7 +438,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
let range = range_param(e, "edit.paste_repeat_to_fill", p)?;
let len = e.clipboard.length.max(1);
let tracks = tracks_required(e, "edit.paste_repeat_to_fill", p)?;
let cb = e.clipboard.clone();
let cb = clipboard_for_paste(e);
let s = e.session_mut();
let mut at = range.start;
let mut n = 0;
@@ -785,25 +810,94 @@ fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"tracks": tracks.len()}))
}
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.strip_silence", p)?;
let r = range_param(e, "edit.strip_silence", p)?;
/// 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);
let (min_len, pre, post) = (ms("min_length_ms", 50.0), ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.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 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}`")))?,
Some(_) => return Err(bad("edit.strip_silence", "mode must be strip, extract or separate")),
};
let names = strip_names("edit.strip_silence", p)?;
let s = e.session_mut();
let mut kept = 0;
let mut named = 0;
for t in &tracks {
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post)?;
kept += crate::io::strip_silence(s, *t, r, thr, min_len, (pre, post), mode, names.is_some())?;
if let Some(names) = &names {
named += crate::io::name_stripped_clips(s, *t, r, names, named);
}
}
Ok(json!({"clips": kept}))
Ok(json!({"clips": kept, "named": named}))
}
/// The naming scheme in the `name`, `number`, `zeros` and `suffix` params of `cmd`
/// (`edit.strip_silence`, `clip.auto_rename`), or `None` when `name` is missing or blank.
pub(super) fn strip_names(cmd: &str, p: &Value) -> Result<Option<crate::io::StripNames>> {
use crate::io::StripNames;
let text = |k: &str| match p.get(k) {
None => Ok(None),
Some(Value::String(v)) => Ok(Some(v.clone())),
Some(_) => Err(bad(cmd, format!("`{k}` must be text"))),
};
let whole = |k: &str, max: u64| match p.get(k) {
None => Ok(None),
Some(v) => v.as_u64().filter(|n| *n <= max).map(Some).ok_or_else(|| bad(cmd, format!("`{k}` must be a whole number from 0 to {max}"))),
};
let (name, suffix) = (text("name")?, text("suffix")?);
let (number, zeros) = (whole("number", StripNames::MAX_NUMBER)?, whole("zeros", StripNames::MAX_ZEROS as u64)?);
let Some(name) = name.filter(|n| !n.trim().is_empty()) else {
if suffix.is_some() || number.is_some() || zeros.is_some() {
return Err(bad(cmd, "`number`, `zeros` and `suffix` need a `name`"));
}
return Ok(None);
};
let d = StripNames::default();
let suffix = suffix.unwrap_or(d.suffix);
let max_text = soundcraft_model::AutoName::MAX_TEXT_CHARS;
if name.chars().count() + suffix.chars().count() > max_text {
return Err(bad(cmd, format!("`name` and `suffix` together must be at most {max_text} characters")));
}
Ok(Some(StripNames { name, number: number.unwrap_or(d.number), zeros: zeros.and_then(|z| usize::try_from(z).ok()).unwrap_or(d.zeros), suffix }))
}
fn fades_create(e: &mut Engine, p: &Value) -> Result<Value> {
let shape = str_param(p, "shape")
.map_or(Some(FadeShape::EqualPower), FadeShape::from_id)
.ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?;
let shape = match p.get("shape") {
None => FadeShape::EqualPower,
Some(v) => v
.as_str()
.and_then(FadeShape::from_id)
.ok_or_else(|| bad("edit.fades_create", "shape must be linear|equal power|s-curve|exponential|logarithmic"))?,
};
if p.get("clip").is_some() || p.get("side").is_some() {
let curvature = match p.get("curvature") {
None => 0.0,
@@ -831,9 +925,6 @@ fn fades_create(e: &mut Engine, p: &Value) -> Result<Value> {
if available <= 0 {
return Err(bad("edit.fades_create", "clip has no length"));
}
if p.get("shape").is_some_and(|v| !v.is_string()) {
return Err(bad("edit.fades_create", "shape must be a fade shape name"));
}
let fade = Fade { len: length.min(available), shape, curvature };
let clip = e.session_mut().find_clip_mut(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?;
if fade_in {
@@ -1070,6 +1161,19 @@ mod tests {
assert_eq!(e.session(), &before);
}
#[test]
fn range_fades_refuse_a_shape_that_is_not_a_shape_name() {
for shape in [json!("bad"), json!(null), json!(true), json!(1)] {
let (mut e, t, _) = trim_session(&[(0, 10_000, 0), (10_000, 10_000, 10_000)]);
let before = e.session().clone();
let history = e.undo_history();
let params = json!({"tracks": [t], "start": 9_500, "end": 10_500, "shape": shape});
assert!(e.execute("edit.fades_create", &params).is_err(), "{params}");
assert_eq!(e.session(), &before, "{params}");
assert_eq!(e.undo_history(), history, "{params}");
}
}
#[test]
fn targeted_fades_support_one_sample_clips() {
let mut e = crate::demo::demo_engine();
+6 -4
View File
@@ -1,11 +1,13 @@
//! More Edit menu commands: Cut/Copy/Clear Special variants, Paste Special, selection helpers,
//! Space Clips, Snap to, Copy/Move Selection to playlists, TCE, and automation coalesce/trim/glide.
use super::{
CommandSpec, always, bad, clip_ids_param, has_clipboard, has_range, has_selection, position_param, range_param, str_param, tracks_required,
};
use super::more_util::{clip_db, render_stretched, replace_clip, set_clip_env, target_playlist};
use super::*;
use crate::edit as ops;
use crate::{Clipboard, TransportRequest, cmd};
use serde_json::json;
use crate::{Clipboard, Engine, Result, TransportRequest, cmd};
use serde_json::{Value, json};
use soundcraft_model::{AutoParam, AutomationLane, Clip, ClipContent, ClipId, EditMode, Playlist, Session};
use soundcraft_time::{Range, Samples};
@@ -450,7 +452,7 @@ fn paste_merge_midi(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "edit.paste_merge_midi";
let tracks = tracks_required(e, id, p)?;
let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0);
let cb = e.clipboard.clone();
let cb = super::edit::clipboard_for_paste(e);
let s = e.session_mut();
let tempo = s.tempo.clone();
let sr = s.sample_rate;
+58 -4
View File
@@ -1,8 +1,8 @@
//! Event menu: time/tempo operations, MIDI operations, markers.
use super::{CommandSpec, always, bad, clip_ids_param, f32_or, f64_or, has_selection, has_tracks, i64_or, position_param, range_param, str_param};
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp};
use soundcraft_model::{ClipContent, MarkerKind};
use soundcraft_time::TICKS_PER_QUARTER;
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
add_marker
),
cmd!("markers.delete", "Delete Memory Location", [], None, "{number | id}", always, delete_marker),
cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?}", always, edit_marker),
cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?: [r,g,b] | null}", always, edit_marker),
cmd!(noundo "markers.recall", "Recall Memory Location", [], Some("Period + n + Period"), "{number}", always, recall_marker),
cmd!(noundo "markers.next", "Go to Next Marker", [], None, "{}", always, |e, _| step_marker(e, 1)),
cmd!(noundo "markers.previous", "Go to Previous Marker", [], None, "{}", always, |e, _| step_marker(e, -1)),
@@ -300,6 +300,7 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result<Value> {
let (a, b) = match (at, st, en) {
(Some(a), _, _) => (a, a),
(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),
};
@@ -337,8 +338,23 @@ fn delete_marker(e: &mut Engine, p: &Value) -> Result<Value> {
fn edit_marker(e: &mut Engine, p: &Value) -> Result<Value> {
let i = marker_index(e, p, "markers.edit")?;
let at = position_param(e, "markers.edit", p, "at")?;
let color = match p.get("color") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Array(a)) if a.len() == 3 => {
let ch = |i: usize| a.get(i).and_then(Value::as_u64).and_then(|v| u8::try_from(v).ok());
match (ch(0), ch(1), ch(2)) {
(Some(r), Some(g), Some(b)) => Some(Some([r, g, b])),
_ => return Err(bad("markers.edit", "`color` components must be integers 0..=255")),
}
}
Some(_) => return Err(bad("markers.edit", "`color` must be [r,g,b] or null")),
};
let s = e.session_mut();
let Some(m) = s.markers.get_mut(i) else { return Err(bad("markers.edit", "gone")) };
if let Some(c) = color {
m.color = c;
}
// `name` is the documented new name; when it was the selector it already matches, so this is a no-op.
if let Some(n) = str_param(p, "rename").or_else(|| str_param(p, "name")) {
m.name = n.to_string();
@@ -443,6 +459,44 @@ mod tests {
assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last"));
}
/// `markers.add` with only `start` or only `end` places the marker there, not at the edit selection.
#[test]
fn markers_add_start_or_end_alone_is_used() {
let mut e = demo_engine();
assert_ne!(e.session().edit.selection.start, 96_000);
let a = e.execute("markers.add", &json!({"name": "A", "start": 96_000})).unwrap();
let b = e.execute("markers.add", &json!({"name": "B", "end": 96_000})).unwrap();
let c = e.execute("markers.add", &json!({"name": "C", "start": 1000, "end": 2000})).unwrap();
let find = |r: &serde_json::Value| e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end));
assert_eq!(find(&a), Some((96_000, 96_000)));
assert_eq!(find(&b), Some((96_000, 96_000)));
assert_eq!(find(&c), Some((1000, 2000)));
}
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
#[test]
fn edit_marker_sets_documented_color() {
let mut e = demo_engine();
let n = e.session().markers.first().unwrap().number;
let color = |e: &crate::Engine| e.session().markers.iter().find(|m| m.number == n).unwrap().color;
assert_eq!(color(&e), None);
e.execute("markers.edit", &json!({"number": n, "color": [255, 0, 0]})).unwrap();
assert_eq!(color(&e), Some([255, 0, 0]));
assert!(e.undo());
assert_eq!(color(&e), None);
assert!(e.redo());
assert_eq!(color(&e), Some([255, 0, 0]));
e.execute("markers.edit", &json!({"number": n, "color": null})).unwrap();
assert_eq!(color(&e), None);
for bad in [json!([1, 2]), json!([1, 2, 300]), json!("red"), json!([1, 2, -3])] {
assert!(e.execute("markers.edit", &json!({"number": n, "color": bad})).is_err());
assert_eq!(color(&e), None);
}
}
/// Constant replaces the tempo over [start, end) only; the tempo at the old end tick carries on after it.
#[test]
fn tempo_constant_is_bounded_by_its_range() {
+60 -11
View File
@@ -2,15 +2,18 @@
//! operations (split notes, input quantize, step input, restore/flatten performance), MIDI track
//! offsets and real-time properties, chord extraction, Beat Detective, Identify Beat and
//! Retrospective Record.
use super::{
CommandSpec, always, bad, bool_or, clip_ids_param, f32_or, f64_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param,
str_param, track_param, tracks_param, tracks_required,
};
use super::more_util::{store_value, track_key, values_with_prefix};
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_midi::Note;
use soundcraft_midi::ops::QuantizeOptions;
use soundcraft_model::{ChannelFormat, Clip, ClipContent, ClipId, MarkerKind, Session, TrackId, TrackKind};
use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER, TempoMap};
use soundcraft_time::{MAX_POSITION, Range, Samples, TICKS_PER_QUARTER, TempoMap};
/// Most tempo events one curve may write.
const MAX_TEMPO_EVENTS: i64 = 10_000;
@@ -414,7 +417,9 @@ fn step_input(e: &mut Engine, p: &Value) -> Result<Value> {
let end = s.tempo.tick_to_samples(end_tick, sr);
// Continue the clip under (or ending at) the insertion point, else start a new one.
let host = s.track(t).and_then(|tr| {
tr.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. })).find(|c| c.range().contains(at) || c.end() == at).map(|c| c.id)
let mut midi = tr.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. }));
// A clip starting at `at` wins over one that merely ends there.
midi.clone().find(|c| c.range().contains(at)).or_else(|| midi.find(|c| c.end() == at)).map(|c| c.id)
});
let host = match host {
Some(h) => h,
@@ -576,8 +581,8 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
let end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
for n in &sequence.notes {
let a = base + n.start;
let b = (base + n.end()).min(end);
let a = base.saturating_add(n.start);
let b = base.saturating_add(n.end()).min(end);
if b > a {
notes.push((a, b, n.pitch));
}
@@ -730,13 +735,16 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
let t = midi_target(e, p, id)?;
let s = e.session();
let sr = s.sample_rate;
// Latest start tick whose longest note still ends within MAX_POSITION.
let max_tick = s.tempo.samples_to_ticks(MAX_POSITION, sr).saturating_sub(TICKS_PER_QUARTER * 1024).max(0);
let mut raw: Vec<(i64, Note)> = Vec::new();
for v in list.iter().take(100_000) {
let tick = match (v.get("start_ticks").and_then(Value::as_i64), v.get("start")) {
(Some(tk), _) => tk.max(0),
(None, Some(_)) => s.tempo.samples_to_ticks(position_param(e, id, v, "start")?.unwrap_or(0).max(0), sr),
_ => return Err(bad(id, "each note needs `start` or `start_ticks`")),
};
}
.min(max_tick);
let note = Note {
pitch: i64_or(v, "pitch", 60).clamp(0, 127) as u8,
velocity: i64_or(v, "velocity", 100).clamp(1, 127) as u8,
@@ -750,7 +758,7 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
let first = raw.iter().map(|x| x.0).min().unwrap_or(0);
// Start the clip on the bar line so the quantize grid lines up with the music.
let clip_tick = s.tempo.bar_start_tick(s.tempo.bar_beat_at_tick(first).bar).min(first).max(0);
let end_tick = raw.iter().map(|(tk, n)| tk.saturating_add(n.length)).max().unwrap_or(first + 1);
let end_tick = raw.iter().map(|(tk, n)| tk.saturating_add(n.length)).max().unwrap_or_else(|| first.saturating_add(1));
let mut seq = soundcraft_midi::Sequence::default();
for (tk, mut n) in raw {
n.start = tk - clip_tick;
@@ -764,7 +772,7 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
seq.sort();
}
let start = s.tempo.tick_to_samples(clip_tick, sr);
let end = s.tempo.tick_to_samples(end_tick, sr);
let end = s.tempo.tick_to_samples(end_tick, sr).min(MAX_POSITION);
let n = seq.notes.len();
let s = e.session_mut();
let cid = s.new_clip_id();
@@ -777,7 +785,6 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
fn midi_engine() -> (Engine, TrackId) {
let mut e = Engine::default();
e.execute("track.new", &json!({"kind": "midi"})).unwrap();
@@ -862,6 +869,25 @@ mod tests {
assert_eq!(e.session().edit.selection.start, 3 * 24_000);
}
#[test]
fn step_input_at_clip_boundary_uses_next_clip() {
let (mut e, t) = midi_engine();
let sr = 48_000;
e.execute("midi.new_clip", &json!({"track": t.0, "start": 0, "end": sr})).unwrap();
let second = e.execute("midi.new_clip", &json!({"track": t.0, "start": sr, "end": 2 * sr})).unwrap()["clip"].as_u64().unwrap();
e.execute("edit.select", &json!({"tracks": [t.0], "start": sr})).unwrap();
let r = e.execute("event.step_input", &json!({"track": t.0, "pitch": 60, "duration": "1/4"})).unwrap();
assert_eq!(r["clip"].as_u64(), Some(second));
let mut ranges: Vec<(i64, i64)> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
ranges.sort();
assert_eq!(ranges, vec![(0, sr), (sr, 2 * sr)]);
let c = e.session().find_clip(soundcraft_model::ClipId(second)).map(|(_, c)| c.clone()).unwrap();
match &c.content {
ClipContent::Midi { sequence } => assert_eq!(sequence.notes.iter().map(|n| n.start).collect::<Vec<_>>(), vec![0]),
_ => panic!(),
}
}
#[test]
fn tempo_curves() {
let mut e = Engine::default();
@@ -943,6 +969,29 @@ mod tests {
assert!(r["bpm"].as_f64().is_some());
}
#[test]
fn extract_chords_survives_huge_note_start() {
let (mut e, t) = midi_engine();
let r = e.execute("midi.new_clip", &json!({"track": t.0, "start": 48_000, "end": 96_000})).unwrap();
let clip = r["clip"].as_u64().unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": i64::MAX, "length_ticks": 480})).unwrap();
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 96_000})).unwrap();
let r = e.execute("event.extract_chords", &json!({}));
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn retro_record_huge_start_ticks_stay_in_range() {
for tk in [i64::MAX, 1_000_000_000_000_000] {
let (mut e, t) = midi_engine();
let notes = json!([{"pitch": 60, "start_ticks": tk, "length_ticks": TICKS_PER_QUARTER * 1024}]);
e.execute("event.retrospective_record", &json!({"track": t.0, "notes": notes})).unwrap();
let c = e.session().track(t).unwrap().clips()[0].clone();
assert!(c.start >= 0 && c.length >= 1, "{tk}: {} {}", c.start, c.length);
assert!(c.start.saturating_add(c.length) <= MAX_POSITION, "{tk}: start {} len {}", c.start, c.length);
}
}
#[test]
fn move_song_start_moves_tick_material() {
let (mut e, t) = midi_engine();
+81 -11
View File
@@ -1,9 +1,12 @@
//! File menu commands.
use super::{
CommandSpec, always, bad, bool_or, clip_ids_param, has_selection, has_tracks, i64_or, position_param, range_param, str_param, track_param,
tracks_param, tracks_required,
};
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{Session, TrackKind};
use crate::{Engine, EngineError, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{Session, TrackId, TrackKind};
use soundcraft_time::SampleRate;
pub fn specs() -> Vec<CommandSpec> {
@@ -85,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true, fold_down?: stereo}", always, bounce),
cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, fold_down?: stereo}", has_tracks, |e, p| {
cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, dither?: true, fold_down?: stereo}", has_tracks, |e, p| {
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
let r = range_param(e, "file.bounce_stems", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
@@ -100,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
_ => soundcraft_audio_io::BitDepth::Int24,
};
let fold = fold_down_param(p, "file.bounce_stems")?;
let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None }, fold)?;
let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }, fold)?;
Ok(json!({"files": files}))
}),
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
@@ -110,9 +113,10 @@ pub fn specs() -> Vec<CommandSpec> {
crate::io::write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}),
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| {
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?, replace?: false}", always, |e, p| {
let text = crate::inspect::session_text(e);
if let Some(path) = str_param(p, "path") {
refuse_replacing(e, "file.export_session_text", std::path::Path::new(path), p)?;
crate::io::write_file(path, text.as_bytes())?;
}
Ok(json!({"text": text}))
@@ -237,9 +241,17 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
let target = import_audio_target(e, p)?;
let mut out = Vec::new();
for path in &paths {
let bytes = crate::io::read_file(path)?;
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string();
let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Audio").to_string();
// Audio the app decoded on a worker thread (`Engine::offer_import`) is only placed here;
// the path is checked the same way either way.
crate::file_access::check_read(std::path::Path::new(path)).map_err(crate::EngineError::Io)?;
let r = match e.take_import(std::path::Path::new(path)) {
Some(d) => crate::io::place_decoded(e, &name, d, Some(path.as_str()), target, at)?,
None => {
let bytes = crate::io::read_file(path)?;
crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?
}
};
out.push(r);
}
Ok(json!({"imported": out}))
@@ -299,7 +311,7 @@ pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {
let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string();
let r = range_param(e, "file.bounce_mix", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
let ext = std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let format_name = str_param(p, "format").unwrap_or(ext.as_str());
let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| {
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS))
@@ -350,6 +362,64 @@ mod tests {
path.to_string_lossy().into_owned()
}
/// Issue #597: the app decodes on a worker thread and hands the audio over; the command then
/// only places it. Audio decoded for another rate is ignored and the file is read again.
#[test]
fn import_audio_places_audio_offered_by_a_worker() {
let dir = std::env::temp_dir().join(format!("sc-import-offered-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
let mut e = Engine::default();
let rate = e.session().sample_rate.hz();
let decoded = std::thread::spawn({
let wav = wav.clone();
move || crate::io::decode_import(&wav, rate)
})
.join()
.unwrap()
.unwrap();
assert_eq!(decoded.frames(), 4_800);
e.offer_import(decoded);
// The file changes after decoding: the offered audio is placed, not the new bytes.
std::fs::write(&wav, b"not audio").unwrap();
let r = e.execute("file.import_audio", &json!({"path": wav, "at": 960})).unwrap();
assert_eq!(r["imported"][0]["frames"], 4_800);
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].clips()[0].start, 960);
assert_eq!(e.session().sources[0].path, wav);
// Taken once: the next import reads the (now broken) file.
assert!(e.execute("file.import_audio", &json!({"path": wav})).is_err());
// Decoded for another rate: ignored, so the broken file is read and refused.
let good = tone_wav(&dir, "good.wav");
let other = crate::io::decode_import(&good, rate / 2).unwrap();
std::fs::write(&good, b"not audio").unwrap();
e.offer_import(other);
assert!(e.execute("file.import_audio", &json!({"path": good})).is_err());
e.drop_offered_imports();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn export_session_text_needs_replace_to_overwrite() {
let dir = std::env::temp_dir().join(format!("sc-export-text-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("info.txt");
let path_str = path.to_string_lossy().into_owned();
let mut e = Engine::default();
e.execute("file.export_session_text", &json!({"path": path_str})).unwrap();
std::fs::write(&path, "keep me").unwrap();
let err = e.execute("file.export_session_text", &json!({"path": path_str})).unwrap_err();
assert!(err.to_string().contains("replace: true"), "{err}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "keep me");
e.execute("file.export_session_text", &json!({"path": path_str, "replace": true})).unwrap();
assert_ne!(std::fs::read_to_string(&path).unwrap(), "keep me");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_audio_honours_new_tracks() {
let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id()));
+56 -42
View File
@@ -1,8 +1,8 @@
//! Media availability checks without decoding files or changing the session.
use super::{CommandSpec, always};
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::ClipContent;
use std::collections::BTreeMap;
@@ -12,6 +12,55 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
struct ClipContext<'a> {
track: &'a soundcraft_model::Track,
playlist: &'a soundcraft_model::Playlist,
active: bool,
session_sample_rate: u32,
}
fn check_audio_clip(
ctx: &ClipContext<'_>,
clip: &soundcraft_model::Clip,
source: soundcraft_model::SourceId,
offset: i64,
metadata: Option<&soundcraft_model::Source>,
audio: Option<&soundcraft_model::SourceAudio>,
issues: &mut Vec<Value>,
) {
let mut add = |code: &str, message: &str| {
issues.push(json!({"code": code, "message": message, "track": ctx.track.name, "track_id": ctx.track.id.0,
"playlist": ctx.playlist.name, "active": ctx.active, "clip": clip.name, "clip_id": clip.id.0,
"source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
};
if metadata.is_none() {
add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
}
let Some(audio) = audio else {
add("unavailable_audio", "Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.");
return;
};
if audio.buffer.sample_rate != ctx.session_sample_rate {
add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
}
if audio.buffer.channels.is_empty() || audio.frames() == 0 {
add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
return;
}
if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
add("uneven_channels", "Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.");
}
if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
}
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
// Match the mixer's last requested source sample, including elastic playback.
let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
if offset < 0 || last >= audio.frames() as f64 {
add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
}
}
fn report(e: &mut Engine, _: &Value) -> Result<Value> {
let s = e.session();
let sources: BTreeMap<_, _> = s.sources.iter().map(|source| (source.id, source)).collect();
@@ -24,51 +73,16 @@ fn report(e: &mut Engine, _: &Value) -> Result<Value> {
for track in &s.tracks {
for (index, playlist) in track.playlists.iter().enumerate() {
let active = index == track.active_playlist;
let ctx = ClipContext { track, playlist, active, session_sample_rate: s.sample_rate.hz() };
for clip in &playlist.clips {
let ClipContent::Audio { source, offset } = clip.content else { continue };
clips += 1;
if active {
active_end = active_end.max(clip.end());
}
let metadata = sources.get(&source);
let audio = s.pool.get(source);
let mut add = |code: &str, message: &str| {
issues.push(json!({"code": code, "message": message, "track": track.name, "track_id": track.id.0,
"playlist": playlist.name, "active": active, "clip": clip.name, "clip_id": clip.id.0,
"source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
};
if metadata.is_none() {
add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
}
let Some(audio) = audio else {
add(
"unavailable_audio",
"Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.",
);
continue;
};
if audio.buffer.sample_rate != s.sample_rate.hz() {
add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
}
if audio.buffer.channels.is_empty() || audio.frames() == 0 {
add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
continue;
}
if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
add(
"uneven_channels",
"Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.",
);
}
if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
}
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
// Match the mixer's last requested source sample, including elastic playback.
let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
if offset < 0 || last >= audio.frames() as f64 {
add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
}
let metadata = sources.get(&source).copied();
let audio = s.pool.get(source).map(|a| a.as_ref());
check_audio_clip(&ctx, clip, source, offset, metadata, audio, &mut issues);
}
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
//! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI
//! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`;
//! the instrument is otherwise only set when the track is created (`track.new`).
use super::{CommandSpec, bad, has_selection, str_param, tracks_required};
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{Insert, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
+46 -6
View File
@@ -1,8 +1,8 @@
//! MIDI note editing (MIDI Editor, pencil on MIDI tracks).
use super::{CommandSpec, always, bad, clip_id, has_tracks, i64_or, range_param, tracks_required};
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, ClipContent, ClipId};
use soundcraft_time::TICKS_PER_QUARTER;
@@ -126,12 +126,23 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
let seq = seq_mut(e, id, "midi.notes_move")?;
for (i, n) in seq.notes.iter_mut().enumerate() {
if idx.contains(&i) {
n.start = (n.start + dt).max(0);
n.pitch = (i64::from(n.pitch) + ds).clamp(0, 127) as u8;
n.start = n.start.saturating_add(dt).max(0);
n.pitch = i64::from(n.pitch).saturating_add(ds).clamp(0, 127) as u8;
}
}
let moved: Vec<Note> = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| *n).collect();
seq.sort();
Ok(json!({"moved": idx.len()}))
// Where the moved notes ended up after sorting, so the editor can keep them selected.
let mut taken = vec![false; seq.notes.len()];
let mut indices = Vec::with_capacity(moved.len());
for m in &moved {
if let Some(j) = seq.notes.iter().enumerate().position(|(j, n)| !taken[j] && n == m) {
taken[j] = true;
indices.push(j);
}
}
indices.sort_unstable();
Ok(json!({"moved": idx.len(), "indices": indices}))
}
#[cfg(test)]
@@ -157,6 +168,35 @@ mod tests {
assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());
}
#[test]
fn notes_move_huge_params_do_not_overflow() {
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();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 480})).unwrap();
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": i64::MAX})).unwrap();
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
assert_eq!(n["notes"][0]["start"], i64::MAX);
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "semitones": i64::MAX})).unwrap();
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
assert_eq!(n["notes"][0]["pitch"], 127);
}
#[test]
fn notes_move_reports_where_the_moved_notes_went() {
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 start in [0, 480, 960] {
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": start})).unwrap();
}
let r = e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": 720})).unwrap();
assert_eq!(r["indices"], json!([1]));
let notes = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
assert_eq!(notes["notes"][1]["start"], 720);
}
#[test]
fn rejects_audio_tracks() {
let mut e = Engine::default();
+247 -25
View File
@@ -1,8 +1,10 @@
//! Mixer, insert, send and automation commands.
use super::{
CommandSpec, always, bad, bool_or, f32_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param, str_param, tracks_required,
};
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, EngineError, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
pub fn specs() -> Vec<CommandSpec> {
@@ -73,7 +75,8 @@ pub fn specs() -> Vec<CommandSpec> {
)),
cmd!("mix.trim", "Input Trim", [], None, "{tracks?, db}", has_selection, |e, p| {
let tracks = tracks_required(e, "mix.trim", p)?;
let db = f32_or(p, "db", 0.0).clamp(-144.0, 24.0);
let db = (p.get("db").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.trim", "`db` (number) required"))? as f32)
.clamp(-144.0, 24.0);
let s = e.session_mut();
for t in tracks {
if let Some(tr) = s.track_mut(t) {
@@ -181,19 +184,24 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Apply a change to `t` and its active mix-group siblings.
fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) {
let mut targets = vec![t];
/// `t` plus the members of its active mix groups that share `attr`.
fn grouped_ids(s: &Session, t: TrackId, attr: &str) -> Vec<TrackId> {
let mut ids = vec![t];
for g in &s.groups {
if g.active && g.mix && g.members.contains(&t) && g.attributes.iter().any(|a| a == attr) {
for m in &g.members {
if !targets.contains(m) {
targets.push(*m);
if !ids.contains(m) {
ids.push(*m);
}
}
}
}
for id in targets {
ids
}
/// Apply a change to `t` and its active mix-group siblings.
fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) {
for id in grouped_ids(s, t, attr) {
if let Some(tr) = s.track_mut(id) {
f(tr);
}
@@ -218,7 +226,13 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
return Err(bad("mix.volume", "`db` or `delta_db` required"));
}
let s = e.session_mut();
// Tracks already moved as a group sibling of an earlier target must not move again.
let mut done: Vec<TrackId> = Vec::new();
for t in &tracks {
if done.contains(t) {
continue;
}
done.extend(grouped_ids(s, *t, "volume"));
let cur = s.track(*t).map_or(0.0, |tr| tr.mixer.volume_db);
let new = match (abs, delta) {
(Some(a), _) => a as f32,
@@ -234,7 +248,8 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
fn pan(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "mix.pan", p)?;
let v = f32_or(p, "pan", 0.0).clamp(-1.0, 1.0);
let v = (p.get("pan").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.pan", "`pan` (number) required"))? as f32)
.clamp(-1.0, 1.0);
let idx = i64_or(p, "index", 0).clamp(0, 1) as usize;
let s = e.session_mut();
for t in &tracks {
@@ -287,17 +302,30 @@ fn slot_of(p: &Value, key: &str, max: usize) -> Option<usize> {
(i < max).then_some(i)
}
fn slot_error(cmd: &str, max: usize) -> EngineError {
bad(cmd, format!("`slot` must be 0..{} or a..{}", max - 1, (b'a' + max as u8 - 1) as char))
}
fn track_slot(e: &Engine, p: &Value, cmd: &str, max: usize) -> Result<(TrackId, usize)> {
let t = tracks_required(e, cmd, p)?.first().copied().ok_or_else(|| bad(cmd, "no track"))?;
let slot = slot_of(p, "slot", max).ok_or_else(|| bad(cmd, format!("`slot` must be 0..{} or a..{}", max - 1, (b'a' + max as u8 - 1) as char)))?;
let slot = slot_of(p, "slot", max).ok_or_else(|| slot_error(cmd, max))?;
Ok((t, slot))
}
/// An optional `slot`: absent (or null) means "first free"; a present but invalid value is
/// an error, never silently treated as absent.
fn optional_slot(p: &Value, cmd: &str, max: usize) -> Result<Option<usize>> {
match p.get("slot") {
None | Some(Value::Null) => Ok(None),
Some(_) => slot_of(p, "slot", max).map(Some).ok_or_else(|| slot_error(cmd, max)),
}
}
fn insert(e: &mut Engine, p: &Value) -> Result<Value> {
let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.insert", "`plugin` required"))?.to_string();
let info = super::clap::plugin_info(&plugin).ok_or_else(|| bad("mix.insert", format!("unknown plugin `{plugin}`")))?;
let t = tracks_required(e, "mix.insert", p)?.first().copied().ok_or_else(|| bad("mix.insert", "no track"))?;
let explicit = slot_of(p, "slot", INSERT_SLOTS);
let explicit = optional_slot(p, "mix.insert", INSERT_SLOTS)?;
let s = e.session_mut();
let tr = s.track_mut(t).ok_or_else(|| bad("mix.insert", "no track"))?;
let slot =
@@ -416,9 +444,9 @@ fn insert_move(e: &mut Engine, p: &Value) -> Result<Value> {
fn send(e: &mut Engine, p: &Value) -> Result<Value> {
let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string();
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
let explicit = slot_of(p, "slot", SEND_SLOTS);
let explicit = optional_slot(p, "mix.send", SEND_SLOTS)?;
let s = e.session_mut();
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
let bid = s.bus_by_name(&bus).map(|b| b.id).ok_or_else(|| bad("mix.send", format!("no bus named \"{bus}\"")))?;
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
// Re-targeting an existing send keeps its level, pan, mute and pre/post setting.
@@ -513,7 +541,7 @@ pub(crate) fn current_value(tr: &soundcraft_model::Track, param: &AutoParam) ->
fn clear_lane(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "automation.clear", p)?;
let param = str_param(p, "param").and_then(AutoParam::parse);
let param = if str_param(p, "param").is_some() { Some(lane_param(p, "automation.clear")?) } else { None };
let r = range_param(e, "automation.clear", p)?;
let (a, b) = if r.is_empty() { (i64::MIN, i64::MAX) } else { (r.start, r.end) };
let s = e.session_mut();
@@ -669,23 +697,35 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
for t in &tracks {
let Some(tr) = s.track_mut(*t) else { continue };
let base = tr.mixer.volume_db;
// An existing lane overrides the fader, so the clip gain rides on top of the lane value.
let old = tr.lane(&AutoParam::Volume).cloned();
let level = |at: i64| old.as_ref().map_or(base, |l| l.value_at(at, base));
let mut pins = Vec::new();
let mut pts = Vec::new();
if let Some(pl) = tr.playlist_mut() {
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
if c.gain_env.is_empty() {
pts.push((c.start, base + c.gain_db));
pts.push((c.end() - 1, base + c.gain_db));
} else {
for (o, db) in &c.gain_env {
pts.push((c.start + o, base + c.gain_db + db));
}
// Pin the unchanged level just outside the clip so the neighbouring segments keep their shape.
if c.start > 0 {
pins.push((c.start - 1, level(c.start - 1)));
}
pins.push((c.end(), level(c.end())));
// The clip edges always get a point, so the envelope's first and last values hold out to them.
let mut ats = vec![c.start, c.end() - 1];
ats.extend(c.gain_env.iter().map(|&(o, _)| c.start + o).filter(|&at| at > c.start && at < c.end() - 1));
// The lane's own points inside the clip carry the clip gain too.
if let Some(l) = &old {
ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1));
}
for at in ats {
pts.push((at, level(at) + c.gain_db + env_db(&c.gain_env, at - c.start)));
}
c.gain_db = 0.0;
c.gain_env.clear();
}
}
let lane = tr.lane_mut(&AutoParam::Volume);
for (at, v) in pts {
// Pins first, so a neighbouring clip's converted points win where they meet.
for (at, v) in pins.into_iter().chain(pts) {
lane.set_point(at, v);
}
}
@@ -694,7 +734,13 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
fn copy_to_send(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "automation.copy_to_send", p)?;
let slot = slot_of(p, "send", SEND_SLOTS).unwrap_or(0) as u8;
// No `send` means Send A (the menu item passes none); a given one must be valid.
let slot = match p.get("send") {
None | Some(Value::Null) => 0,
Some(_) => slot_of(p, "send", SEND_SLOTS).ok_or_else(|| {
bad("automation.copy_to_send", format!("`send` must be 0..{} or a..{}", SEND_SLOTS - 1, (b'a' + SEND_SLOTS as u8 - 1) as char))
})?,
} as u8;
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
@@ -838,6 +884,77 @@ mod tests {
}
}
#[test]
fn clip_gain_to_volume_keeps_the_existing_lane() {
let clip_gain_to_volume = |points: &[(i64, f32)], env: Vec<(i64, f32)>| {
let (mut e, t) = tone_session(points);
let clip = e.session().track(t).unwrap().clips()[0].id;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.gain_db = 6.0;
c.gain_env = env;
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
let after = rendered(e.session());
assert!(e.session().find_clip(clip).unwrap().1.gain_db == 0.0);
(before, after)
};
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], Vec::new());
let delta = rms_db(&before, &after);
assert!(delta.abs() < 0.02, "level changed by {delta} dB");
// A moving lane keeps its shape, and its points inside the clip carry the gain.
let (before, after) = clip_gain_to_volume(&[(0, -12.0), (4096, -6.0), (8192, -12.0)], Vec::new());
for (start, end) in [(0, 2048), (2048, 4096), (4096, 6144), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
}
// The clip's own gain envelope rides on the lane value as well.
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], vec![(2000, 0.0), (6000, -10.0)]);
for (start, end) in [(3900, 4100), (5000, 5200)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_holds_the_envelope_ends_out_to_the_clip_edges() {
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
let clip = e.session().track(t).unwrap().clips()[0].id;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.gain_db = 6.0;
c.gain_env = vec![(2000, 0.0), (6000, -10.0)];
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
let after = rendered(e.session());
for (start, end) in [(0, 1000), (1000, 2000), (6000, 7000), (7000, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_leaves_audio_outside_the_clip_alone() {
// Three clips in a row; only the middle one is converted, and the lane extends past it on both sides.
let (mut e, t) = tone_session(&[(0, -6.0), (16384, -6.0)]);
let (middle, last) = (e.session_mut().new_clip_id(), e.session_mut().new_clip_id());
let pl = e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap();
if let Some(c) = pl.clips.first_mut() {
c.length = 2048;
}
pl.clips.push(Clip::audio(middle, "mid", SourceId(910), 2048, 2048, 4096));
pl.clips.push(Clip::audio(last, "end", SourceId(910), 6144, 6144, 2048));
e.session_mut().find_clip_mut(middle).unwrap().gain_db = 6.0;
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 2048, "end": 6144})).unwrap();
let after = rendered(e.session());
assert!(e.session().find_clip(middle).unwrap().1.gain_db == 0.0);
for (start, end) in [(0, 2040), (2056, 6136), (6152, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn insert_params_rejects_unknown_and_non_numeric_keys() {
let mut e = Engine::default();
@@ -874,16 +991,121 @@ mod tests {
assert_eq!(e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().preset, "Saved");
}
#[test]
fn copy_to_send_rejects_an_invalid_send_slot() {
let mut e = crate::demo::demo_engine();
let history = e.undo_history().len();
let lanes = |e: &Engine| e.session().tracks.iter().filter(|t| t.lane(&AutoParam::SendLevel(0)).is_some_and(|l| !l.points.is_empty())).count();
let before = lanes(&e);
for bad_params in [json!({"track": "Kick", "send": 99}), json!({"track": "Kick", "send": "zz"})] {
let r = e.execute("automation.copy_to_send", &bad_params);
assert!(r.is_err(), "{bad_params} accepted: {r:?}");
assert_eq!(e.undo_history().len(), history, "{bad_params} pushed an undo step");
assert_eq!(lanes(&e), before, "{bad_params} edited Send A");
}
// No `send` (the menu item's call) still means Send A.
e.execute("automation.copy_to_send", &json!({"track": "Kick"})).unwrap();
assert_eq!(lanes(&e), before + 1);
// A valid slot still copies.
e.execute("automation.copy_to_send", &json!({"track": "Kick", "send": "a"})).unwrap();
assert_eq!(lanes(&e), before + 1);
}
#[test]
fn retargeting_a_send_keeps_its_settings() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
assert_eq!(sl.target, soundcraft_model::Route::Bus(delay));
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
}
fn grouped_demo() -> Engine {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &json!({"tracks": [3], "db": 0.0})).unwrap();
e.execute("mix.volume", &json!({"tracks": [6], "db": 0.0})).unwrap();
e.execute("track.group", &json!({"tracks": [3, 6]})).unwrap();
e
}
fn volumes(e: &Engine) -> Vec<f32> {
[3, 6].iter().map(|id| e.session().track(TrackId(*id)).unwrap().mixer.volume_db).collect()
}
#[test]
fn volume_delta_on_a_whole_group_applies_once() {
let mut e = grouped_demo();
e.execute("mix.volume", &json!({"tracks": [3, 6], "delta_db": -3.0})).unwrap();
assert_eq!(volumes(&e), [-3.0, -3.0]);
let mut e = grouped_demo();
e.execute("mix.volume", &json!({"tracks": [3], "delta_db": -3.0})).unwrap();
assert_eq!(volumes(&e), [-3.0, -3.0]);
}
#[test]
fn volume_absolute_on_a_whole_group_keeps_offsets() {
let mut e = grouped_demo();
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = -2.0;
e.execute("mix.volume", &json!({"tracks": [3, 6], "db": -10.0})).unwrap();
assert_eq!(volumes(&e), [-10.0, -12.0]);
}
#[test]
fn clear_with_unknown_param_keeps_lanes() {
let (mut e, t) = tone_session(&[(0, -6.0), (100, -3.0)]);
e.session_mut().track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0)).set_point(0, 0.5);
e.session_mut().edit.selection = Range::new(0, 0);
let count = |e: &Engine| e.session().track(t).unwrap().automation.iter().map(|l| l.points.len()).sum::<usize>();
let r = e.execute("automation.clear", &json!({"track": t.0, "param": "volum"}));
assert!(r.is_err(), "unknown param succeeded: {r:?}");
assert_eq!(count(&e), 3, "unknown param changed the lanes");
e.execute("automation.clear", &json!({"track": t.0, "param": "volume"})).unwrap();
assert_eq!(count(&e), 1, "named param cleared the wrong lanes");
e.execute("automation.clear", &json!({"track": t.0})).unwrap();
assert_eq!(count(&e), 0, "absent param must still clear every lane");
}
#[test]
fn send_rejects_unknown_bus() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err();
assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}");
assert_eq!(e.session().busses.len(), 1);
assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none));
e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap();
assert_eq!(e.session().busses.len(), 1);
}
#[test]
fn insert_and_send_reject_invalid_slot() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
for slot in [json!(12), json!("k"), json!("x1")] {
let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"}));
assert!(r.is_err(), "insert slot {slot} accepted: {r:?}");
}
for slot in [json!(12), json!("k")] {
let r = e.execute("mix.send", &json!({"track": "Gtr", "slot": slot, "bus": "Verb"}));
assert!(r.is_err(), "send slot {slot} accepted: {r:?}");
}
let mixer = &e.session().tracks[0].mixer;
assert!(mixer.inserts.iter().all(Option::is_none));
assert!(mixer.sends.iter().all(Option::is_none));
// Leaving `slot` out still picks the first free slot.
let r = e.execute("mix.insert", &json!({"track": "Gtr", "plugin": "compressor"})).unwrap();
assert_eq!(r["slot"], 0);
let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
assert_eq!(r["slot"], 0);
}
}
+34 -2
View File
@@ -8,6 +8,7 @@ use std::sync::OnceLock;
mod audio_midi;
mod audiosuite;
mod auto_rename;
mod clap;
mod clip;
mod clip_list;
@@ -38,9 +39,31 @@ mod view_more;
mod vst3;
mod zoom_toggle;
#[cfg(test)]
mod panic_probe {
use super::*;
use crate::cmd;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(noundo "test.panic_probe", "Panic Probe", [], None, "{}", always, probe)]
}
fn probe(e: &mut Engine, p: &Value) -> Result<Value> {
if p.get("panic").is_none() {
return Ok(Value::Null);
}
e.clipboard.length = 123;
e.message("probe");
e.transport_requests.push(crate::TransportRequest::Play);
e.last_selection = Some((soundcraft_time::Range::new(0, 10), Vec::new()));
panic!("probe")
}
}
pub use file::bounce_job;
pub(crate) use edit::remember_selection;
pub use edit::strip_silence_scan;
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
/// `Err(reason)` = disabled.
@@ -81,7 +104,7 @@ impl CommandSpec {
CommandInfo {
id: self.id.into(),
label: self.label.into(),
menu: self.menu.iter().map(|s| s.to_string()).collect(),
menu: self.menu.iter().map(ToString::to_string).collect(),
shortcut: self.shortcut.map(str::to_string),
params: self.params.into(),
enabled: en.is_ok(),
@@ -126,6 +149,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(track_more::specs());
v.extend(clip_more::specs());
v.extend(clip_list::specs());
v.extend(auto_rename::specs());
v.extend(event_more::specs());
v.extend(setup_more::specs());
v.extend(clap::specs());
@@ -136,6 +160,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(sampler::specs());
v.extend(video::specs());
v.extend(zoom_toggle::specs());
#[cfg(test)]
v.extend(panic_probe::specs());
v
})
}
@@ -224,13 +250,19 @@ pub fn tracks_param(e: &Engine, cmd: &str, p: &Value) -> Result<Vec<TrackId>> {
if let Some(arr) = p.get("tracks").and_then(Value::as_array) {
let s = e.session();
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for v in arr {
let id = match (v.as_u64(), v.as_str()) {
(Some(n), _) => s.track(TrackId(n)).map(|t| t.id),
(_, Some(name)) => s.track_by_name(name).map(|t| t.id),
_ => None,
};
out.push(id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?);
let id = id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?;
// Duplicates multiply the work (each entry renders and keeps its own result), so drop
// repeated ids instead of letting them blow the budget.
if seen.insert(id) {
out.push(id);
}
}
return Ok(out);
}
+3 -3
View File
@@ -1,8 +1,9 @@
//! Helpers shared by the newer command modules: settings kept in `EditState.flags`/`values`,
//! per-track settings, target playlists, clip-gain envelopes and stretch rendering.
use super::bad;
use crate::{Engine, Result};
use super::*;
use serde_json::json;
use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId};
use soundcraft_time::Samples;
@@ -187,7 +188,6 @@ pub fn replace_clip(s: &mut Session, t: TrackId, old: soundcraft_model::ClipId,
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_settings_round_trip() {
let mut ed = EditState::default();
+3 -3
View File
@@ -1,8 +1,8 @@
//! Options menu toggles and Setup › Session settings.
use super::{CommandSpec, always, bad, bool_or, position_param, str_param};
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{BitDepthSetting, EditState};
use soundcraft_time::FrameRate;
+25 -3
View File
@@ -1,9 +1,10 @@
//! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's
//! state right before saving and stores it on its insert (base64), from where the mixer restores
//! it into new instances.
use super::{CommandSpec, bad, has_tracks, track_param};
use crate::{Engine, Result};
use super::*;
use serde_json::json;
use serde_json::{Value, json};
use soundcraft_model::{INSERT_SLOTS, b64};
pub fn specs() -> Vec<CommandSpec> {
@@ -47,6 +48,15 @@ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {
Some(Value::String(s)) => Some(b64::decode(s).ok_or_else(|| bad(CMD, "`state` is not valid base64 (or is too large)"))?),
Some(_) => return Err(bad(CMD, "`state` must be a base64 string or null")),
};
let n = state.as_ref().map_or(0, Vec::len);
// Plugins report changes that leave their state as it was: don't modify the session for those.
let stored = e.session().track(t).and_then(|tr| match slot {
Some(i) => tr.mixer.inserts.get(i).and_then(Option::as_ref),
None => tr.instrument.as_ref(),
});
if stored.is_some_and(|ins| ins.state_bytes().as_deref() == state.as_deref().filter(|b| !b.is_empty())) {
return Ok(json!({"bytes": n}));
}
let s = e.session_mut();
let tr = s.track_mut(t).ok_or_else(|| bad(CMD, "no track"))?;
let ins = match slot {
@@ -54,7 +64,6 @@ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {
None => tr.instrument.as_mut(),
}
.ok_or_else(|| bad(CMD, "empty slot"))?;
let n = state.as_ref().map_or(0, Vec::len);
match state {
Some(bytes) => ins.set_state_bytes(&bytes),
None => ins.state = None,
@@ -96,6 +105,19 @@ mod tests {
assert_eq!(e.session().tracks[0].mixer.inserts[2].as_ref().unwrap().state, None);
}
#[test]
fn storing_the_same_state_again_changes_nothing() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let t = e.session().tracks[0].id.0;
e.execute("mix.insert", &json!({"track": t, "plugin": "gain", "slot": 0})).unwrap();
let blob = b64::encode(b"same");
e.execute("mix.insert_state", &json!({"track": t, "slot": 0, "state": blob})).unwrap();
let rev = e.revision;
e.execute("mix.insert_state", &json!({"track": t, "slot": 0, "state": blob})).unwrap();
assert_eq!(e.revision, rev);
}
#[test]
fn hostile_params_are_errors() {
let mut e = Engine::default();
+2 -2
View File
@@ -1,8 +1,8 @@
//! Read-only queries for agents.
use super::{CommandInfo, CommandSpec, always, bad, command_specs, has_tracks, position_param, str_param, track_param};
use super::*;
use crate::cmd;
use serde_json::json;
use serde_json::{Value, json};
pub fn specs() -> Vec<CommandSpec> {
vec![
+6 -6
View File
@@ -4,11 +4,11 @@
//! The source is saved in `Audio Files/` with the session like any other audio, so a session that
//! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set
//! with `mix.instrument_param`, or all at once with `params` here.
use super::{CommandSpec, bad, has_selection, str_param, tracks_required};
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{Insert, TrackKind};
use crate::{Engine, EngineError, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{Insert, TrackId, TrackKind};
use std::path::Path;
const SAMPLER: &str = "sampler";
@@ -56,7 +56,7 @@ fn load(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
let bytes = crate::io::read_file(&path)?;
let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string();
let name = Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Sample").to_string();
attach(e, t, &name, &bytes, Some(&path), values)
}
@@ -69,7 +69,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
return Err(bad(id, "only instrument tracks have a sampler"));
}
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
let ext = Path::new(name).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
if buf.frames() == 0 || buf.num_channels() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
+36 -4
View File
@@ -1,10 +1,10 @@
//! More Options toggles, the Setup menu (hardware, playback engine, I/O, timecode, click,
//! shortcuts, MIDI setup) and File › Import › Session Data.
use super::{CommandSpec, always, bad, command_specs, i64_or, str_param, track_param, tracks_param};
use super::more_util::{set_text, store_value, text, toggle_flag, values_with_prefix};
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, EngineError, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind};
use soundcraft_time::Timecode;
use std::path::Path;
@@ -461,6 +461,8 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
s.sources.push(ns);
}
let mut out = Vec::new();
// The donor's Auto Rename schemes come along under new keys, shared as they were.
let mut names: std::collections::BTreeMap<u64, u64> = Default::default();
for mut tr in picked {
tr.id = TrackId(s.alloc());
tr.name = s.unique_track_name(&tr.name);
@@ -474,6 +476,14 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
{
*source = *n;
}
c.auto_name = c.auto_name.and_then(|k| {
let scheme = other.auto_names.get(&k)?;
Some(*names.entry(k).or_insert_with(|| {
let (key, id) = (s.alloc(), crate::new_scheme_id(s));
s.auto_names.insert(key, soundcraft_model::AutoName { id, ..scheme.clone() });
key
}))
});
}
}
// The donor's source ids mean nothing here: remap the sample, or drop it when it didn't come along.
@@ -503,7 +513,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn option_flags_toggle() {
let mut e = Engine::default();
@@ -602,6 +611,29 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_session_data_brings_auto_rename_schemes_under_new_keys() {
let dir = std::env::temp_dir().join(format!("sc-import-names-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("donor.scraft").to_string_lossy().into_owned();
let mut donor = crate::demo::demo_engine();
let kick: Vec<_> = donor.session().track_by_name("Kick").unwrap().clips().iter().map(|c| c.id.0).collect();
donor.execute("clip.auto_rename", &json!({"clips": kick, "name": "Hit", "number": 5})).unwrap();
crate::io::save_session(&mut donor, &path).unwrap();
let mut e = crate::demo::demo_engine();
let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id;
e.execute("clip.auto_rename", &json!({"clips": [bass], "name": "Low"})).unwrap();
e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Kick"]})).unwrap();
let s = e.session();
assert_eq!(s.auto_names.len(), 2, "the donor's scheme comes in beside this session's");
let imported = s.tracks.iter().rfind(|t| t.name.starts_with("Kick")).unwrap();
let keys: std::collections::BTreeSet<_> = imported.clips().iter().map(|c| c.auto_name).collect();
assert_eq!(keys.len(), 1, "the imported clips still share one scheme");
let key = keys.into_iter().next().flatten().unwrap();
assert_eq!((s.auto_names[&key].name.as_str(), s.auto_names[&key].number), ("Hit", 5));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_session_data_brings_a_samplers_sample() {
let dir = std::env::temp_dir().join(format!("sc-import-sampler-{}", std::process::id()));
+4 -3
View File
@@ -1,8 +1,8 @@
//! Surround mixing: the main output format and the surround panners of tracks and sends.
use super::{CommandSpec, always, bad, bool_or, f32_or, has_selection, str_param, tracks_required};
use super::*;
use crate::cmd;
use serde_json::json;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{ChannelFormat, Mixer, OutputPath, Route, SEND_SLOTS, Session, SurroundPan, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
@@ -184,6 +184,7 @@ mod tests {
assert!(e.session().track(t).unwrap().mixer.surround.is_none());
// Sends.
assert!(e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0})).is_err(), "empty slot");
e.execute("mix.new_bus", &json!({"name": "FX"})).unwrap();
e.execute("mix.send", &json!({"track": t.0, "slot": 0, "bus": "FX"})).unwrap();
e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0, "y": -1.0})).unwrap();
let snd = e.session().track(t).unwrap().mixer.sends[0].clone().unwrap();
+5 -5
View File
@@ -1,10 +1,10 @@
//! Tab and Tab to Transient: the insertion point or selection end to the next or previous clip boundary, transient or note.
use super::{CommandSpec, always, bool_or};
use super::*;
use crate::{TransportRequest, cmd};
use serde_json::json;
use soundcraft_model::{Clip, ClipContent, Session, SourceAudio};
use soundcraft_time::SampleRate;
use crate::{Engine, Result, TransportRequest, cmd};
use serde_json::{Value, json};
use soundcraft_model::{Clip, ClipContent, Session, SourceAudio, TrackId};
use soundcraft_time::{Range, SampleRate, Samples};
use std::sync::{Arc, Mutex, PoisonError, Weak};
/// Transients are found in fixed blocks of each clip, so a long clip costs a block per press and
+185 -9
View File
@@ -1,9 +1,11 @@
//! Track menu commands.
use super::{
CommandSpec, always, bad, bool_or, has_selection, has_tracks, i64_or, range_param, str_param, track_param, tracks_param, tracks_required,
};
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackKind};
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{ChannelFormat, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -19,6 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("track.group", "Group...", ["Track"], Some("Cmd+G"), "{name?, tracks?, edit?: true, mix?: true}", has_selection, group),
cmd!("track.ungroup", "Delete Group", [], None, "{group: id|name}", always, delete_group),
cmd!("track.group_toggle", "Toggle Group Active", [], None, "{group: id|name}", always, toggle_group),
cmd!("track.group_modify", "Modify Group", [], None, "{group: id|name, name?, tracks?, edit?, mix?}", always, modify_group),
cmd!(
"track.duplicate",
"Duplicate...",
@@ -320,6 +323,55 @@ fn toggle_group(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"active": active}))
}
/// Change an existing group's name, membership and Edit/Mix linking. Omitted fields keep their value.
fn modify_group(e: &mut Engine, p: &Value) -> Result<Value> {
const CMD: &str = "track.group_modify";
let g = find_group(e, p, CMD)?;
// Absent `tracks` leaves membership alone; present, it replaces it. Only an array counts, so a
// mistyped param can't silently swap the members for whatever happens to be selected.
let members = match p.get("tracks") {
None => None,
Some(Value::Array(_)) => {
let mut t = tracks_param(e, CMD, p)?;
// A track listed twice would take every linked move twice over.
let mut seen = std::collections::HashSet::new();
t.retain(|id| seen.insert(*id));
if t.is_empty() {
return Err(bad(CMD, "a group needs at least one track"));
}
Some(t)
}
Some(_) => return Err(bad(CMD, "`tracks` must be an array of track ids or names")),
};
let name = match str_param(p, "name") {
None => None,
Some(n) => {
let n = n.trim();
if n.is_empty() {
return Err(bad(CMD, "`name` must not be empty"));
}
// `find_group` resolves by name, so duplicates would make a group unaddressable.
if e.session().groups.iter().any(|x| x.name == n && x.id != g) {
return Err(bad(CMD, format!("a group named `{n}` already exists")));
}
let mut n = n.to_string();
soundcraft_model::truncate_chars(&mut n, soundcraft_model::MAX_NAME_CHARS);
Some(n)
}
};
let s = e.session_mut();
let Some(gr) = s.groups.iter_mut().find(|x| x.id == g) else { return Err(bad(CMD, "unknown `group`")) };
if let Some(n) = name {
gr.name = n;
}
if let Some(m) = members {
gr.members = m;
}
gr.edit = bool_or(p, "edit", gr.edit);
gr.mix = bool_or(p, "mix", gr.mix);
Ok(json!({"group": g, "name": gr.name, "tracks": gr.members, "edit": gr.edit, "mix": gr.mix}))
}
fn duplicate(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.duplicate", p)?;
let (pl, auto, ins, snd) =
@@ -362,6 +414,8 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.split_into_mono", p)?;
let s = e.session_mut();
let mut out = Vec::new();
let mut mono_sources = std::collections::BTreeMap::new();
let mut copied_samples = 0usize;
for t in tracks {
let Some(src) = s.track(t).cloned() else { continue };
if src.channels() < 2 || src.kind != TrackKind::Audio {
@@ -377,12 +431,46 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
let mut clips = src.clips().to_vec();
for c in &mut clips {
c.id = s.new_clip_id();
// Mono tracks read channel 0 of the source; record the channel in the name for now.
if let ClipContent::Audio { source, .. } = &mut c.content {
let audio =
s.pool.get(*source).ok_or_else(|| bad("track.split_into_mono", format!("audio unavailable for clip `{}`", c.name)))?;
let channels = audio.buffer.channels.len();
if channels == 0 {
return Err(bad("track.split_into_mono", format!("audio has no channels for clip `{}`", c.name)));
}
// Match the renderer's channel wrapping when a track is wider than its source.
// An already-mono source can be shared without copying it.
if channels > 1 {
let key = (*source, ch % channels);
let mono = if let Some(&id) = mono_sources.get(&key) {
id
} else {
let samples =
audio.buffer.channels.get(key.1).ok_or_else(|| bad("track.split_into_mono", "source channel unavailable"))?;
copied_samples = copied_samples
.checked_add(samples.len())
.filter(|&n| n <= crate::io::MAX_IMPORT_SAMPLES)
.ok_or_else(|| bad("track.split_into_mono", "split would copy too much audio"))?;
// Keep the full channel: the cloned clips retain their offsets, stretch,
// gain and fades, and every reader sees genuine mono media (including saves).
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![samples.clone()] };
let original = s.source(*source);
let name = format!("{}.{suffix}.wav", original.map_or(c.name.as_str(), |source| source.name.as_str()));
let time_reference = original.map_or(0, |source| source.time_reference);
let id = crate::io::add_source(s, &name, buf, None, soundcraft_audio_io::FileFormat::Wav);
if let Some(source) = s.sources.iter_mut().find(|source| source.id == id) {
source.time_reference = time_reference;
}
mono_sources.insert(key, id);
id
};
*source = mono;
}
}
c.name = format!("{}.{suffix}", c.name);
}
if let Some(tr) = s.track_mut(id) {
tr.mixer.pan = vec![if ch == 0 { -1.0 } else { 1.0 }];
tr.comments = format!("source_channel={ch}");
if let Some(pl) = tr.playlist_mut() {
pl.clips = clips;
}
@@ -493,6 +581,15 @@ fn folder_bus(s: &Session, folder: TrackId) -> Option<Route> {
s.track(folder).map(|folder| folder.mixer.input.clone()).filter(|route| matches!(route, Route::Bus(_)))
}
/// The summing bus owned by a routing folder.
fn owned_folder_bus(s: &Session, folder: TrackId) -> Option<soundcraft_model::BusId> {
let folder = s.track(folder).filter(|track| track.kind == TrackKind::Folder)?;
match folder.mixer.input {
Route::Bus(id) => Some(id),
_ => None,
}
}
/// 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()
@@ -577,6 +674,20 @@ fn change_width(e: &mut Engine, p: &Value) -> Result<Value> {
fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.delete", p)?;
let s = e.session_mut();
let removed_busses: Vec<_> = tracks
.iter()
.filter_map(|folder| {
let bus = owned_folder_bus(s, *folder)?;
let mut parent = s.track(*folder).and_then(|folder| folder.folder);
for _ in 0..64 {
match parent {
Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
_ => break,
}
}
Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main)))
})
.collect();
// Promote children of deleted folders and repair only routes that used the deleted
// folder's own bus. Custom outputs remain untouched.
let mut promoted = Vec::new();
@@ -607,21 +718,48 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
for g in &mut s.groups {
g.members.retain(|t| !tracks.contains(t));
}
for (bus, replacement) in removed_busses {
s.busses.retain(|candidate| candidate.id != bus);
for track in &mut s.tracks {
if track.mixer.input == Route::Bus(bus) {
track.mixer.input = Route::None;
}
if track.mixer.output == Route::Bus(bus) {
track.mixer.output = replacement.clone();
}
for send in &mut track.mixer.sends {
if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) {
*send = None;
}
}
}
}
Ok(json!({"deleted": tracks.len()}))
}
fn rename(e: &mut Engine, p: &Value) -> Result<Value> {
let t = track_param(e, "track.rename", p, "track")?.ok_or_else(|| bad("track.rename", "`track` required"))?;
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("track.rename", "`name` required"))?;
let s = e.session_mut();
// Validate the stored name, including its existing 64-character limit.
let name: String = name.chars().take(64).collect();
let s = e.session();
if s.tracks.iter().any(|x| x.name == name && x.id != t) {
return Err(bad("track.rename", format!("a track named `{name}` already exists")));
}
let name: String = name.chars().take(64).collect();
let bus = s.track(t).and_then(|track| match (track.kind, &track.mixer.input) {
(TrackKind::Folder, Route::Bus(bus)) => Some(*bus),
_ => None,
});
let track = s.track(t).ok_or_else(|| bad("track.rename", "unknown track"))?;
if track.name == name
&& bus.is_none_or(|id| {
let expected = unique_folder_bus_name(s, &format!("{name} Bus"), Some(id));
s.bus(id).is_none_or(|bus| bus.name == expected)
})
{
return Ok(json!({"name": name}));
}
let s = e.session_mut();
if let Some(track) = s.track_mut(t) {
track.name.clone_from(&name);
}
@@ -855,7 +993,6 @@ fn route(e: &mut Engine, p: &Value, input: bool) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn comp_from_alternate_playlist() {
let mut e = crate::demo::demo_engine();
@@ -872,6 +1009,35 @@ mod tests {
assert!(e.execute("track.playlist_promote", &json!({"track": kick, "playlist": 1, "start": 0, "end": 10})).is_err());
}
#[test]
fn group_modify_changes_name_membership_and_linking() {
let mut e = crate::demo::demo_engine();
let g = e.execute("track.group", &json!({"tracks": ["Kick", "Snare"], "name": "Drums"})).unwrap()["group"].as_u64().unwrap();
// Every field at once, the way the dialog submits it.
let r = e.execute("track.group_modify", &json!({"group": g, "name": "Rhythm", "tracks": ["Kick", "Bass"], "edit": false})).unwrap();
assert_eq!(r["name"], "Rhythm");
let gr = e.session().groups.iter().find(|x| x.id.0 == g).unwrap().clone();
assert_eq!(gr.name, "Rhythm");
assert_eq!(gr.members, vec![e.session().track_by_name("Kick").unwrap().id, e.session().track_by_name("Bass").unwrap().id]);
assert!(!gr.edit);
assert!(gr.mix, "an omitted field keeps its value");
assert_eq!(gr.letter, 'a', "the letter is the group's stable id and never changes");
// Omitting `tracks` leaves membership alone rather than falling back to the selection.
e.execute("edit.select", &json!({"tracks": ["Pad"], "exact": true})).unwrap();
e.execute("track.group_modify", &json!({"group": g, "name": "Rhythm Section"})).unwrap();
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().members, gr.members);
// Resolving the group by its new name keeps working, so names stay unique and non-empty.
e.execute("track.group", &json!({"tracks": ["Pad"], "name": "Pads"})).unwrap();
assert!(e.execute("track.group_modify", &json!({"group": "Pads", "name": "Rhythm Section"})).is_err());
assert!(e.execute("track.group_modify", &json!({"group": g, "name": " "})).is_err());
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": []})).is_err());
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": "Kick"})).is_err(), "`tracks` must be an array");
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": ["Kick", "Kick"]})).is_ok());
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().members.len(), 1, "a repeated track is listed once");
assert!(e.execute("track.group_modify", &json!({"group": 9999, "name": "x"})).is_err());
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().name, "Rhythm Section", "a rejected call changes nothing");
}
#[test]
fn folder_routes_members_through_its_bus() {
let mut e = crate::demo::demo_engine();
@@ -920,6 +1086,14 @@ mod tests {
e.execute("track.move_to_folder", &json!({"tracks": ["Kick"], "folder": inner.0})).unwrap();
e.execute("track.move_to_folder", &json!({"tracks": [inner.0], "folder": outer.0})).unwrap();
let inner_bus = match e.session().track(inner).unwrap().mixer.input {
Route::Bus(bus) => bus,
_ => panic!("folder did not receive a bus"),
};
let outer_bus = match e.session().track(outer).unwrap().mixer.input {
Route::Bus(bus) => bus,
_ => panic!("folder did not receive a bus"),
};
assert!(e.execute("track.move_to_folder", &json!({"tracks": [outer.0], "folder": inner.0})).is_err());
let kick = e.session().track_by_name("Kick").unwrap();
@@ -946,6 +1120,8 @@ mod tests {
let kick = e.session().track_by_name("Kick").unwrap();
assert_eq!(kick.folder, None);
assert_eq!(kick.mixer.output, Route::Main);
assert!(e.session().bus(inner_bus).is_none(), "the deleted folder's owned bus must be removed");
assert!(e.session().bus(outer_bus).is_some(), "the surviving parent folder's bus must remain");
}
#[test]

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