* 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.
* 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.
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.
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.
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>
`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.
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>
256 MiB per line times 16 connections let a peer make the app buffer about
4 GiB. 16 MiB would break a legitimate request: mix.insert_state carries a
plugin's state as base64 inline, and the engine accepts states of up to
b64::MAX_DECODED_BYTES (64 MiB), about 85.3 MiB of base64. Nothing else sent
to the app is that large (screenshots are replies, which this doesn't bound).
So the cap is that base64 length plus 1 MiB for the JSON around it, derived
from the same constant so the two stay in step; a compile-time check keeps it
under 96 MiB, about 1.5 GiB across all 16 connections.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bounce keeps its own snapshot of the session, decoded audio included.
The progress window blocks the mouse, but shortcuts, the macOS menu bar,
dropped files and the control channel still reach SoundApp::run, so a
second session could be opened beside the one bouncing; two large ones
could exhaust memory, which aborts the app.
New, Open, Close and Revert now fail with "Finish or cancel the bounce
first" while a bounce runs, and so do sessions opened or dropped in the
browser.
Nothing caught a panic outside a command: one in the interface's frame
code ended the process in that frame (exit code 101), losing every
unsaved change, and the log got a single line with no backtrace.
Each frame's logic and drawing now run guarded. A panic there autosaves
the session (once per change, so a bug that repeats every frame doesn't
flood the disk), says so in the status line and lets the app carry on.
If the app goes down after all, the autosave is offered at the next
launch. The panic hook now logs a backtrace.
Autosave wrote every session called "Untitled" to the same
Autosave/Untitled folder and reused audio files there by source id, so
a second untitled session, or a take recorded after an undo, could be
autosaved with another take's audio. Its first run also copied all of
the session's audio again. Nothing ever offered an autosave back
(latest_autosave had no callers), so after a crash the work could only
be found by digging in the settings folder.
Each open session now autosaves to a folder of its own, locked while
the session is open. Audio already saved with the session is referenced
where it is; audio that exists only in memory is copied once per
recording or render. Saving, closing the session, opening another and
quitting with SoundCraft's Quit delete the folder. A crash, or an exit
that skips Quit with unsaved changes, leaves it behind, after one last
autosave when SoundCraft gets the chance. At the next launch,
SoundCraft offers each kept folder, newest first, to recover or
discard. Recovered work opens as an unsaved version of the session it
came from, so Save writes it back there; unsaved changes in the session
it replaces are kept the same way. Folders older than their session
file are dropped.
Pro Tools has File › Open Recent; SoundCraft had no way back to a session but
browsing for it. The list (UiState.recent_sessions) records the engine's
session path whenever it changes, so opening or saving by any route (menu,
control channel, MCP, a dropped file) is covered by one rule.
- Under File › Open; each entry is "name — folder", full path on hover.
- A session that has moved or been deleted is greyed out "(missing)" instead
of failing on click; existence is re-checked at most every 3 s while the menu
is open, since a network share costs a round trip per file.
- Opening goes through the dialog layer, so unsaved changes are asked about.
- Clear Recent empties it.
- The desktop host saves ui.json as soon as the list changes, not only on a
clean exit: the list matters most after a crash.
- An older ui.json without the field still loads (serde default).
The macOS system menu bar (native_menu) does not show it yet; the egui menu
bar on Windows and Linux does.
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>
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.
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.
convert ignored a --rate that did not parse and mapped any unknown
--bit-depth to 24-bit, then reported success. Both options are now
validated before the input is read; an invalid value prints which
option was wrong and exits non-zero without writing output. Omitted
options keep their defaults.