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>
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.
Fixes#282: on an empty MIDI or instrument track there was no UI way to
get a MIDI clip, so notes couldn't be entered by hand. `midi.new_clip`
existed but had no menu item, key or tool.
- Consolidate now works on MIDI and instrument tracks. The MIDI in the
selection becomes one clip covering the whole selection, gaps
included. It keeps the sound: muted clips stay silent, and notes are
cut where their clip ended. An empty selection gives an empty clip,
as in Pro Tools. Audio tracks are unchanged.
- The Pencil draws a MIDI clip on an empty part of a MIDI or instrument
track: a click makes one bar, a drag covers the bars it crosses, with
an outline while dragging. Clips start and end on barlines, the rule
Pro Tools uses for MIDI it creates. A clip stops at its neighbour
rather than overwriting it, and clicks on existing clips add nothing.
The new clip is selected, so the MIDI Editor shows it.
- With an empty MIDI or instrument track selected, the MIDI Editor says
so, and a click adds a one-bar clip at the insertion point (or a clip
over the selection).