When a third-party plugin was removed and re-added (same id, same channel
count) and both session updates reached the audio thread in one drain, sync
saw an unchanged slot, kept the old instance and retired the new one as an
unclaimed leftover. The host-side editor belonged to the new, retired
instance, so reopening it failed with "the plugin is gone", and the old
instance kept running with no editor.
The host only hands over an instance when it no longer tracks the one in
the slot, so an instance waiting for a slot now always replaces it (inserts
and instruments). Debug logs record instance creation and release on the
UI thread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
write_automation merged a pass's points under one key, but the fader
driving the pass merged under its own, so each frame of a ride started
two new undo steps: one second of riding made 120, and in two seconds
everything before the pass was gone from the 200-step history. Stop's
last point was a step of its own as well, because every command,
transport.stop included, ended the gesture.
Engine::hold_merge makes every merged gesture take the held key until
it is released. While playing with a track in a write mode,
write_automation holds the pass's key, so the pass and whatever is
dragged during it are one undo step. A command now ends a gesture only
when it changes the document, so Stop no longer splits the pass it
ends.
Strip Silence rendered the whole selection at once, and a render stops
at 2^29 samples over all channels: 93 minutes of stereo at 48 kHz, 46
at 96 kHz. Everything past that read as silence, so the clips there
were deleted. A 105-minute selection on a stereo track lost its clip at
100 minutes.
It now renders at most 2^24 samples at a time, only where unmuted audio
clips play, into a streaming version of the silence detector
(`SilenceScan`, which `non_silent_ranges` now runs on). A window takes
at most 64 MB instead of up to 2 GiB, and more than a day of clips is
refused rather than scanned for minutes. `render_clips` now skips clips
outside its range before looking up their clip effects, so a track of
5,000 clips strips as fast as before. Results are unchanged on the demo
sessions and on 10 minutes of tone bursts that cross window edges.
From #186 and #305: right-clicking a track's name did nothing. In the
Edit window the track menu (Rename, Duplicate, Make Inactive, Hide,
Delete, height, colour…) was only on the header's background, and the
name plate drawn over it took the click. The Mix window had no track
menu at all. Pro Tools opens it from the name in both windows.
Both name plates now open the same track menu on a right-click. Clicks,
double-click to rename and dragging to reorder are unchanged.
From #186 ("Adjusting send levels can get stuck"). The send readout in
the Mix window had three problems:
- A drag stopped at −60 dB, so a send couldn't be dragged off, and a
new send (−∞) jumped to −60 on the first drag. Drags now move along
the fader's taper, like the level bar under the send name: smoothly
down to −∞ and back up, at about the old 0.15 dB a point near 0 dB.
- Its tooltip promised "double-click for 0 dB", but the first click
already turned the readout into a text field, so that never ran.
Alt-click now resets it to 0 dB, as on the fader. The volume and pan
readouts reset on Alt-click too (0 dB, centre), since all three share
the readout, and Alt-click no longer opens their text field.
- The text field stayed open when it lost focus while hidden (switching
windows with it open, for longer than a frame), and a later Enter
anywhere committed its stale text. Now it gives the readout back, and
Enter only commits while the field has focus.
Applying new audio device settings replaced the player while plugin
editor windows were open: the old instances went away with it (possibly
on the audio thread, where windows cannot be closed) and their editor
windows stayed on screen orphaned. Player::close_editors closes them on
the main thread first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From #307: adding a track took the Track menu or Shift+Cmd+N
(Shift+Ctrl+N on Windows and Linux). Nothing on screen offered it, not
even in an empty session.
- The TRACKS header in the Tracks list has a + button that opens New
Tracks. The list is in both the Edit and the Mix window.
- A double-click in the empty space below the last track in the Edit
window (header column or timeline) opens New Tracks, as in Pro Tools.
A single click there still clears the selection.
- A double-click right of the last strip in the Mix window does the
same. With no tracks, that is the whole window.
Window > Mix in Separate Window (window.mix_separate, kept with the UI
preferences) shows the Mix window in its own native window through an
egui immediate viewport, so Edit and Mix can be seen at once, e.g. one
per monitor. The main window then keeps Edit; Window > Mix and
Cmd/Ctrl+= bring the Mix window to the front; closing it puts Mix back
into the main window. Backends without multiple windows show it as a
floating egui window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes#220: Render in the Signal Generator window did nothing on a time
selection over an empty part of a track. AudioSuite only processed the
clips under the selection, so with none it stopped with "select audio
clips first", and that went to the status line only.
- A generator (soundcraft_dsp::is_generator, today only the Signal
Generator) ignores its input, so it no longer needs clips. With a
time selection it renders a new clip over exactly the selection on
each selected audio track, over whatever was there, as Pro Tools'
Signal Generator does. Before, it replaced whole clips under the
selection and left the empty parts empty.
- Other tracks in the selection are skipped. With no audio track in
it, or no time selection and no clips, it reports why.
- Explicit `clips` still process those clips. Effects behave as before.
- The AudioSuite window's hint says what Render will do for a
generator.
One offline render holds MAX_RENDER_SAMPLES samples, about 93 minutes
of stereo at 48 kHz, and the offline time stretch, pitch shift and
resample stop at MAX_OFFLINE_SAMPLES frames. Longer audio was cut short
without a word: Render Clip Gain and Clip Effects Render left the end
of the clip silent, every AudioSuite process shortened the clip, the
track Commit made fell silent at the limit, and Export Clips wrote
shortened files. Above 74 kHz an hour no longer fits, so TCE, Elastic
Properties and Remove Pitch Shift padded the cut end with silence.
Each now checks the length before it renders and refuses with the
limit in seconds, as Consolidate does in #212. AudioSuite counts what
Time Stretch, Pitch Shift and Varispeed make, so a fourfold stretch is
refused at a quarter of the limit. Export Clips checks every clip
before it writes a file.
module_exit_shutdown asserts that the exit handler alone runs the module
exit too late. That holds where exit handlers share one list. Windows
runs the executable's handlers before a DLL's own, so the fixture's late
state is still alive and the child exits cleanly; the test failed there.
The Windows case now asserts the clean exit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The VST3 bindings type C enum constants as i32 on Windows and u32
elsewhere, so assigning kDefaultActive to BusInfo.flags (u32) failed to
compile and seven integration tests could not build their fixture.
Cast it like the other constants in this file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saved_audio_is_trusted_only_in_the_folder_it_was_saved_in compared path
strings. On Windows the engine reports <session folder>\Audio Files/x.wav
while the test expected <folder>/Audio Files/x.wav, so the test failed
there although the two name the same file. The expected value is now the
session folder joined with the stored relative path, on every platform.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows the test body was compiled out but its setup was not, so
`lib` was an unused variable and clippy -D warnings failed the gate.
The test never ran anything on Windows; it is now cfg(unix) as a whole.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rust 1.95's clippy flags the nested if (collapsible_match), so
cargo xtask ci failed on a machine with the README's minimum toolchain.
No behaviour change: other MP3 sample entries still fall through to the
catch-all arm.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Setup > Playback Engine now chooses the audio system (cpal host), output
and input device, device sample rate and buffer size, kept per machine
with the UI preferences and applied by reopening the devices (refused
while recording; the transport position is kept). ui.audio_device does
the same for agents and lists every system with its devices, channels,
rates and buffer range.
Player and recorder open the chosen device, fall back to the defaults
for anything unavailable (unplugged device, unsupported rate), retry
with the device's default buffer when the chosen one is refused, and
convert 32/16-bit integer devices from and to the f32 mix through
scratch buffers allocated up front (no allocation in the callbacks).
ASIO is behind an opt-in 'asio' feature (playback and app): building it
downloads Steinberg's ASIO SDK (proprietary licence or GPLv3) and needs
LLVM, so it stays off by default. Verified on Windows with an SSL 2+:
WASAPI output/input switching and buffer 256, ASIO duplex at 128.
Docs: G-01 shrunk, hardware-parity rows, roadmap log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From #186: waveform zoom could not be zoomed back out, zoom presets had
no keys, and N did nothing for Insertion Follows Playback.
The registry writes three shortcuts as key families: "Cmd+Ctrl+1-5"
(Zoom Preset), "Cmd+Alt+0-5" (Clip Rating) and "Cmd+Alt+[ / ]"
(Waveform Zoom). The shortcut parser can't read them, so they never
fired. The menus still showed them.
- The keyboard handler takes these keys itself, as it does F1-F4:
Cmd+Alt+[ and ] zoom the waveforms out and in, Cmd+Alt+0-5 rate the
selected clips and Cmd+Ctrl+1-5 recall the zoom presets. Other digits
do nothing.
- Waveform Zoom and Clip Rating work on every OS (Ctrl+Alt on Windows
and Linux). Cmd+Ctrl+1-5 stays a macOS key, like the other Mac
Control chords, which the shortcut list already leaves out off macOS.
- Commands Focus (the a-z button) gains Pro Tools' 1-5 for the zoom
presets and N for Insertion Follows Playback, on every OS.
Plugins that offer no floating editor (most CLAP plugins on Windows) now
get a host window of SoundCraft's: the GUI is created embedded, parented
to a top-level HWND owned by the app window, sized from get_size, resized
on request_resize (applied by the editor's idle on the main thread, then
set_size), and torn down when the user closes the window. Floating
editors work as before; other platforms still report embedded editors
as unsupported.
Docs: plugin-parity editor rows and the roadmap log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a Windows implementation of the editor host window: a top-level HWND
owned by the app's active window (no taskbar button), sized from the
view's rect, resized on IPlugFrame::resizeView, hidden on close and torn
down by the editor link's idle like on macOS. The main thread is the one
Rust names "main", where the app runs its message loop.
Also fixes two Windows-only build problems that kept the VST3 tests from
running there: the fixture's bus flags (the vst3 bindings use i32 enums
with MSVC) and an unused variable in a unix-only test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once a session has any edit.values entry (click settings, Real-Time
Properties, clip effects), MixEngine::render allocated every block,
although the crate docs say steady-state rendering doesn't: every audio
clip formatted two Strings and collected a Vec to look up its clip
effects, and every MIDI clip formatted four keys for its Real-Time
Properties. Strips render on rayon's workers while the audio callback
waits for them, or on the audio thread itself below 4 strips.
Lookups now format their keys on the stack (Key) and range over
edit.values with &str bounds. A new test checks that Real-Time
Properties still reach the instrument.
A counting allocator on all threads, over 1000 blocks of the demo
session with a clip effect on every clip and Real-Time Properties on
every track, saw 10 to 43 allocations per block before and none after,
apart from rayon's job queue (a new chunk every 63 blocks) and the
one-time setup of each clip's effects when it first plays.
Every document change sends the audio callback a new Arc<Session>.
Engine::session_mut clones the document whenever the callback still holds
the previous snapshot, so after a non-undoable edit (selection, scrolling)
and on every step of a merged gesture (a fader drag) the callback held the
only reference to the snapshot it replaced. Replacing it freed the whole
tree (tracks, clips, automation, plugin state strings) inside the audio
callback: 268 frees for the demo session, and more for real ones.
The callback now hands the replaced snapshot back over a bounded channel
(preallocated, so try_send never allocates), and the UI thread drops it in
Player::idle, as it does retired plugin instances. update_session empties
the channel before each send, so it holds one or two at a time; if it is
ever full, the snapshot is dropped in place, as before.
One insert or instrument putting out NaN or infinity silenced the whole
mix: the value spread through the busses and the main mix (NaN plus
anything is NaN), and the output guard turned all of it into silence.
It can also poison the state of filters, reverbs and compressors
downstream, which then stay silent after the plugin has calmed down.
After each insert (track or master) and each instrument processes, the
engine checks its block. If the block holds NaN or infinity, the engine
silences that block of the strip and skips the plugin until the next
MixEngine::reset (playback starts or stops), which resets it and tries
it again. Meanwhile the strip plays on without it, as if bypassed. The
check costs about 60 ns per insert for a stereo 512-sample block.
StripMeter::plugin_failures counts how often a strip lost a plugin and
names the slot of the latest, so the UI names that plugin in the status
line each time, also when it fails again after a restart. The AU host
no longer zeroes non-finite output itself, so Audio Units are taken out
and named like CLAP and VST3 plugins.
When a third-party plugin refused its stored state (a newer plugin
version that can't read it, a damaged blob), it ran with its defaults,
and the next save or two-minute autosave read its state back into the
session: the saved settings were gone, even after updating the plugin.
create_instance now says whether the plugin refused the state
(NewInstance::refused_state). Playback leaves such an instance out of
the states it reads for saving, so the session keeps the stored state,
until the plugin's own editor is opened: from then on its state is the
user's work. Parameter values are saved apart from the state, so edits
made with SoundCraft's own controls survive either way.
Saving, bouncing and the two-minute autosave read every third-party
plugin's state through the audio callback: a command made the callback
call each plugin's save_state between two blocks and reply, while the
UI waited for up to 2 s. So:
- CLAP's clap.state and VST3's getState ran on the audio thread,
although both formats want them on the main thread.
- The callback allocated and waited on the plugins; a plugin with a
large state (a sampler) could make the audio drop out on every save
and autosave.
- Without a running callback (a stalled or lost device), every save
waited 2 s and then kept stale plugin states.
Each instance now hands Side::prepare a state reader before it leaves
for the audio thread, the way it hands over its editor handle. CLAP's
GuiLink and VST3's EditorLink (which now holds the component as well)
read the state under their lock; Audio Units get a StateLink that does
the same. Each instance cuts its link off before it is destroyed, so a
reader never calls into a dead plugin. Player::capture_states reads
them on the calling (UI) thread, and the CaptureStates command is gone.
Each host's tests read the state while another thread processes the
instance, and get nothing once it is dropped. Playback reads states
with no audio thread at all.
event.midi_track_offsets stored midi.offset.<track> but the note
scheduler never read it, so the render was unchanged. schedule_notes
now looks that many samples back into the clips, which plays the track
exactly as if its MIDI had been moved by the offset. The scheduler is
shared by offline bounce and realtime playback.
A write pass added a point every 20 ms of playback and never removed
the old points between them, so the old automation survived between
the new points and the level jumped between the two rides. A covered
window, which runs only once a second since #192, wrote one point a
second and kept almost all of the old ride.
automation.set_point takes an optional `from`, and the points between
`from` and `at` are removed first. Each point of a pass gives the
previous one, so it replaces everything since then however far apart
the passes run, and Stop writes a last point where playback stopped.
A locate or a restart while playing starts afresh, so a jump never
erases what it skipped.
The test writes over a dense old ride with passes a quarter second
apart, jumps twice and stops, then checks each stretch. Removing any
of the four changes fails it.
Built-in instruments now open a window with their parameters (driven by
mix.instrument_param). The Sampler's window adds a waveform with draggable
start and loop markers, Load Sample and Clear. Dropping an audio file on an
instrument track or a Sampler window loads it; in the browser the dropped
bytes are read and loaded through engine::io::load_sampler_bytes as one undo
step.
Setup > Current Feet+Frames Position stored its origin in
feet_frames.start but nothing read it, so every Feet+Frames reading
still counted from sample 0. format_position/parse_position now take
the origin alongside the timecode start, and Session::format_position
and Session::parse_position pass both, so the counters, rulers,
session.format_time and typed positions share one reference.
M4B is the same MP4/AAC container as M4A, and the decoder already reads it,
but the Open dialog, the web file picker and the file browser hid .m4b files.
Add m4b to those lists and to the extension fallback.
From #186: the Bounce dialog had no Browse button, so the file path had
to be typed, and it always proposed the home folder.
- A Browse… button opens the system save panel with an Audio filter
(WAV, AIFF, FLAC). The chosen file goes in the field, and its
extension sets the Format.
- A saved session bounces into its own folder by default; unsaved
sessions still start in the home folder.
- The desktop save panel now picks its filter by purpose (bounce or
session) and opens in the proposed file's folder when it exists, so
Save As starts there too instead of wherever the panel was last.
- The file field is 420pt wide, like the other path prompts.
From #186 ("markers created using Numpad Enter cannot be renamed"):
double-clicking a marker in a marker ruler added a second marker on top
of it, whose label overprinted the first. Renaming was only possible
from the Memory Locations list.
- Double-clicking a marker's flag now opens a Rename Marker dialog,
like double-clicking a marker in Pro Tools opens its memory location.
The field opens with the old name selected, as Rename Track and
Rename Clip do, and OK runs `markers.edit`.
- Double-clicking empty ruler space still adds a marker, and a single
click on a flag still recalls it.
The first point of #189: clicking and dragging the toolbar tempo did
nothing, because it was painted text. Typing a tempo needed Tempo
Operations or a tempo-ruler dialog.
- The tempo is now a drag value styled like the counter. It looks the
same when idle and shows a faint frame on hover. Drag to change it
(Shift for fine steps, 0.1 BPM per point otherwise), or click and
type a value. The range is 5-1000 BPM, as the tempo map allows.
- It edits the tempo change in effect at the playhead, the one the
field shows, so later tempo changes are left alone.
- A drag is one undo step.
Fixes#170: dragging a row in the Tracks list did nothing. Pro Tools
reorders tracks when their names are dragged in the Track List, and
the Edit window's name plates already did that here.
- Rows now sense drags. While dragging, a line marks the gap between
rows where the track will land, and the cursor shows a grab.
- Dropping runs `track.move`, so it is one undo step. Dropping a row
back on its own gap changes nothing.
- The gap comes from the row pitch (row height plus the list's item
spacing), so long drags land where the line is.
- A click still selects the track as before.
Trim to File Boundaries and Trim to Fill Selection extended a clip within
its source without looking at the clips around it, leaving two unfaded
clips over the same samples. Both now go through one helper that caps the
extension at the end of the previous clip and the start of the next one on
the same playlist, the way the other commands that grow an existing clip
do (Paste Merge MIDI, step input). An existing crossfade overlap is kept.