package.sh already honors CARGO_BUILD_TARGET: cargo builds for the target, the binaries are read
from target/<triple>/release, and the arch comes from the target rather than uname -m. Only the
header still described x86_64 alone, so update it.
* test(cli): pin today's argument handling
Run the real binary and record how the hand-written parser behaves for every
subcommand: help and version spellings, no arguments, unknown subcommands and
flags, value flags in both spellings, missing and repeated values, positional
order, exit codes and which stream carries each message.
No behaviour change. Later parser changes update these assertions on purpose.
* refactor(cli): split main.rs into modules
Move only: args, error, output, convert, info, session, catalog, app and
mcp each get their functions and unit tests; main.rs keeps the usage doc,
help_text and the dispatch. Moved items become pub(crate) where other
modules call them. No behaviour change.
* refactor(cli): typed errors and run(args, out, err)
soundcraft-cli is now a library plus a thin binary. `soundcraft_cli::run(args,
out, err) -> i32` is the whole program; main.rs only passes the real streams
and shuts the plugin hosts down afterwards (process teardown, not part of run,
which tests call repeatedly in one process). Subcommands write to the given
writers, return `Result<(), CliError>`, and `fail` is gone. CliError wraps
engine, audio-io, io (with the path), serde_json and the control channel
errors; UsageError, ConvertError and RootFlagError cover the subcommands' own
failures. No Result<_, String> is left except where a dependency reports a
string (control-channel call, AutomationRoots::new), wrapped in a variant.
The tests that need no files, working directory, environment or stdin now call
`run` in-process (tests/it/run.rs); the rest still run the binary.
Behaviour changes:
- Error messages start with `error: ` instead of `soundcraft-cli: `.
- A script line that stops the run now prints `error: FILE:N: ID: ...` (it had
no prefix). With --keep-going the line stays bare.
- A convert encode or write failure now names the output path.
- An empty or help-less call still prints the one-line usage to stderr without
a prefix and exits 1; `files confined:` keeps its `soundcraft-cli:` prefix
(it is a note, not an error).
* refactor(cli): parse arguments with clap
Replace the hand parser (arg_value, check_options, ENGINE_OPTIONS, the
lib.rs dispatch, the per-subcommand positional handling and the root
flags' consume) with clap derive types in args.rs: a Parser, a
Subcommand enum and one Args struct per subcommand, with the run/script
engine flags flattened. main passes args_os(); run takes OsString-like
arguments and renders clap's help, version and usage errors (help and
version on stdout, exit 0; usage errors on stderr, exit 2). Add
command() returning the clap definition. The usage block in the module
doc and help_text are gone: the doc comments are the help.
Behaviour changes:
- Usage errors exit 2 with clap's message and the --help hint on
stderr, instead of exit 1 with hand-written text: unknown subcommand,
unknown flag, missing value, missing or extra positional, a value on
a boolean flag (--demo=1, --normalize=1).
- No arguments prints the help to stderr, exit 2 (was a one-line usage,
exit 1).
- --help, -h and help print clap's generated help, and every subcommand
has its own (SUB --help, SUB -h, help SUB). Before, info and script
read --help as a file name, describe as an id, commands as a filter,
run rejected --help and ignored -h, convert and app printed their
usage error, mcp, parity and plugins ignored it, and app --port P -h
called the method -h.
- --help or -h before -- shows the subcommand's help even after a
mistake on the same line (run --bounce --help).
- --version, -V and version print "soundcraft-cli X.Y.Z" (was
"SoundCraft CLI X.Y.Z").
- Unknown flags on info, commands, describe, app, mcp, parity and
plugins are usage errors; they were ignored or read as a positional.
mcp --automation-bogus exits 2 (was exit 1).
- Extra positionals are usage errors on every subcommand (were ignored).
- A single-dash word (-x) is a flag, so an unknown one is a usage
error; a value starting with - is written --flag=-value.
- Flags may come before or between the positionals: convert IN --rate
44100 OUT, convert --rate 44100 IN OUT and script --keep-going FILE
parse (the flag word was taken as a file name).
- A repeated single-value flag keeps its last value (was the first):
--rate, --bit-depth, --save, --port and the others.
- parity --write without a value is a usage error (printed the report).
- mcp --connect without a value, or followed by a flag, is a usage
error (was exit 1, or took the flag as the port).
- mcp --demo=1 is a usage error (was ignored, without the demo).
- A root flag without a folder, with an empty one, or followed by a
flag is a usage error with clap's message (was exit 1).
- A non-Unicode argument is a usage error where text is expected and
accepted as a root folder (std::env::args panicked).
- The help lists --sample as --demo's alias.
Unchanged: --dither with --no-dither, bad --rate and --bit-depth values,
the roots with --connect and the SOUNDCRAFT_CONTROL_PORT fallback are
still checked after parsing (exit 1).
* refactor(cli): reject repeated flags, clap --version (photocraft#7071 decisions)
Behaviour changes:
- A flag given twice is a usage error (exit 2), in either spelling, including
an alias pair such as --demo and --sample. Before, the last one won. Only
run's --cmd and mcp's --automation-read-root / --automation-write-root
repeat; they accumulate in order.
- --version and -V print the long version, "soundcraft-cli 0.13.1 (linux
x86_64)": the version and the platform the binary was built for.
- The hidden `version` subcommand is gone; `soundcraft-cli version` is an
unrecognized-subcommand usage error.
- --start and --end accept a negative number (`--start -1`, as the engine
counts positions before 0); any other value starting with `-` needs `=`
and otherwise is an unexpected-argument usage error. No flag has
allow_hyphen_values.
- `help <subcommand>` is unchanged (tested).
* refactor(cli): typed values and flag relations
Handlers receive typed values: convert --bit-depth is a ValueEnum, --rate a
NonZeroU32, file arguments PathBuf, --start/--end a TimeArg, --cmd a parsed
(id, JSON) spec, app's JSON a serde_json::Value, app --port and mcp --connect
a validated port or host:port. Relations (--dither/--no-dither, --in/--demo,
--start/--end needing --bounce) are declared on the flags. describe ID,
commands FILTER and app METHOD refuse an empty string. parse_rate,
parse_bit_depth, parse_dither, parse_cmd_spec, time_arg and the Rate,
BitDepth, DitherConflict and CmdJson errors are gone.
Behaviour changes:
- --dither with --no-dither is a usage error (exit 2) instead of exit 1.
- --rate 0 / non-numeric and an unknown --bit-depth are usage errors (exit 2)
with clap's wording, naming the flag. --bit-depth help lists the values.
- --in with --demo (or --sample) is a usage error; --in used to win silently.
- --start/--end without --bounce are a usage error; they used to be ignored.
- Malformed JSON in --cmd ID=JSON and in app's JSON argument is a usage error
(exit 2) at parse time. Before, run opened the engine and executed every
earlier --cmd (printing their results) before the bad one failed with exit 1.
- An empty --start/--end, an empty command id (--cmd =1), and an empty
describe ID, commands FILTER or app METHOD are usage errors. An empty
commands FILTER used to list everything.
- app --port and mcp --connect accept only a port (0-65535) or host:port;
junk used to be accepted and failed at connect time (exit 1).
- Non-Unicode file names are accepted wherever the callee takes a Path (info
audio files, convert, script, parity --write). Session and bounce/save paths
go to the engine as text, so a non-Unicode one fails with exit 1, "the path
is not valid Unicode", after any earlier --cmd ran.
* refactor(cli): shared launch flags and SOUNDCRAFT_CONTROL_PORT as clap env
soundcraft_automation::args holds the launch flags as clap Args structs:
ControlClientArgs (`app --port`, env SOUNDCRAFT_CONTROL_PORT, default
7979), ControlServerArgs (the desktop `--control`, same variable, wired
in a later step; its ControlPort value is a port, also from a loopback
host:port, or another host's host:port, which the app decides on, so one
export serves both), AutomationArgs (the two root flags, repeatable, flags
only), the ControlTarget value parser, and the blank_env_is_unset,
name_env_vars and conflict helpers. The module and its clap/thiserror
dependencies are left out of the wasm build. `app` and `mcp` flatten
them; the hand fallback to the variable in `app` is gone.
Behaviour changes:
- An empty or blank SOUNDCRAFT_CONTROL_PORT counts as unset, so `app`
uses 7979; before, it tried to connect to `127.0.0.1:` (exit 1).
- A bad SOUNDCRAFT_CONTROL_PORT is a usage error naming the variable
(exit 2); before, the connection failed (exit 1).
- Spaces around the `app --port` or `mcp --connect` value, or the
variable's, are ignored; before, the connection failed (exit 1).
- An IPv6 host in `app --port`, `mcp --connect` or the variable goes in
brackets (`[::1]:7979`); `::1:7979` is a usage error (exit 2), as the
host and port can't be told apart; before, the connection failed
(exit 1).
- `app --help` shows `[env: SOUNDCRAFT_CONTROL_PORT=]` and
`[default: 7979]` instead of the hand-written default note.
- `mcp` with a root flag and --connect is clap's conflict error (exit 2,
"the argument '...' cannot be used with '...'"), reported before any
root folder is opened; before, a custom message with exit 1, after the
roots were opened.
* refactor(app): move launch flag parsing into launch_args
The parsing moves out of main unchanged, into a LaunchArgs struct and a
parse function that takes SOUNDCRAFT_CONTROL_PORT as a parameter. Tests pin
the flags, the file list, ignored unknown flags and the fallback of a bad
--control to the variable. No behaviour change.
* refactor(app): parse launch flags with clap
soundcraft --help now comes from the LaunchArgs doc comments and ends with the
environment variables the app reads, and the shared ControlServerArgs supplies
--control and SOUNDCRAFT_CONTROL_PORT. Process tests cover the variable (bad
value, blank value, loopback host:port, another host).
Behaviour changes:
- An unknown flag (--bogus, -x) is a usage error on stderr, exit 2. It was
ignored, and a single-dash word was opened as a file; after `--` it is a file.
- A bad --control (out of range, not a number, missing value) is a usage error,
exit 2. It was ignored and the variable used instead.
- A bad SOUNDCRAFT_CONTROL_PORT is a usage error naming the variable, exit 2.
It was ignored. An empty or blank variable still counts as unset.
- A loopback host:port (127.0.0.1, localhost or [::1]) in --control or
SOUNDCRAFT_CONTROL_PORT gives its port, so the value `soundcraft-cli app`
takes from the variable works for both. The control server listens on
127.0.0.1 only, so another host in --control is a usage error. In the
variable, which may point `soundcraft-cli app` at another machine, it is
ignored with a log warning and the app opens without a control server.
Both were ignored before.
- A repeated flag is a usage error (--demo twice, --control twice).
- --control=PORT and a port with surrounding spaces are accepted; a value that
starts with `-` is not taken as the value, except a negative number, which is
a bad port (`--control -1`).
- --help ends with an Environment section listing SOUNDCRAFT_CONTROL_PORT,
SOUNDCRAFT_CONFIG_DIR, SOUNDCRAFT_NO_PREFS, SOUNDCRAFT_NO_SYSTEM_FONTS,
SOUNDCRAFT_WAYLAND (Linux) and RUST_LOG.
- --help and -h print the help, exit 0. They were ignored.
- --version and -V print `soundcraft 0.13.1 (linux x86_64)`, as soundcraft-cli
does. It printed `SoundCraft 0.13.1`.
- -V and --version are read in order with the other arguments: an earlier
error comes first.
- macOS -psn_… process serial numbers are dropped, as before they were
opened as files.
- A file whose name is not valid Unicode is skipped with a log warning. It
used to panic in std::env::args.
* feat(cli): shell completions and man pages
An example, `cargo run -p soundcraft-cli --example man-and-completions -- <out-dir>`,
writes bash, zsh, fish, elvish and PowerShell completions and soundcraft-cli.1 plus a
page per subcommand, using clap_complete and clap_mangen (dev-dependencies of
soundcraft-cli only, not in the binary).
The Linux package (deb, rpm, tar.gz, AppImage) installs the pages in
/usr/share/man/man1 and the bash, zsh and fish completions in their standard
directories; the deb also links the zsh completion into Debian's
vendor-completions. A cross build runs the example through $EMULATOR and
leaves the files out without one. The FreeBSD tarball carries them in the
ports layout; a cross build builds the example for the host as well.
No change to the CLI's behaviour. A test pins the files the example writes.
* ci(cli): check the command-line code with ast-grep rules
Adds lint/ast-grep (rules for clap errors built from strings, String as an
error type, and std::env::args) and a workflow that runs the rule tests and a
scan over the CLI code through the shared craft-common-actions workflow. No
behaviour change; the scan has no hits.
* docs(cli): document soundcraft-cli and RFC-0012
Add docs/cli.md: the subcommands, exit codes, environment variables, the
desktop app's launch flags, man pages and completions, and the RFC-0012 rules
not followed yet (convert can overwrite its input; no --compact/--json).
Link it from the README and the MCP and control-protocol guides. No code change.
* feat(cli): refuse an output that replaces an input
An output that is one of the subcommand's inputs is a usage error (exit 2,
`error: …` on stderr, clap's usage line), reported while parsing, before
the engine loads or any --cmd runs. Paths are compared by file identity
(device and inode, through same-file), or, when neither exists yet, by
where they would be created, so `a.wav`, `./a.wav`, an absolute path and a
symbolic or hard link all count as the same file.
Inputs and outputs per subcommand:
- convert: input IN, output OUT. --in-place lets OUT replace IN.
- run: input --in, outputs --bounce and --save. --in-place lets --save
replace the --in session; it requires --in. A --bounce naming the --in
session is always refused.
- script: inputs FILE and --in, outputs --bounce and --save. --in-place
lets --save replace the --in session; a --bounce naming it is always
refused, and neither may ever replace FILE.
- run and script: --bounce and --save naming one file is refused with or
without --in-place (the save would replace the bounce).
- info, commands, describe, app, mcp, plugins write no file; parity
--write has no input. Paths inside --cmd parameters are not checked.
With --in-place, an output that is a symbolic link to the input is written
to the file the link names, so convert and --save both replace the input
and leave the link in place (a save renames a new file over its path,
which would otherwise replace the link and leave the input unchanged).
Behaviour changes:
- `convert a.wav a.wav`, `run --in s.scraft --save s.scraft`,
`run --in s.scraft --bounce s.scraft` and the other cases above exit 2
instead of replacing the input.
- New flag --in-place on convert, run and script.
- An existing file that is not an input is still replaced without asking.
* feat(cli): --compact for JSON output
RFC-0012 rule 1.3: JSON is pretty-printed by default and --compact prints
one line. Every pretty-printed JSON output now goes through
output::print_json.
Changed outputs:
- info <audio file>, describe, app: new --compact flag.
- run and script: new --compact flag, which requires --inspect and applies
to the --inspect JSON only.
Unchanged on purpose:
- The `ID: JSON` result lines of run and script and the --bounce and --save
results stay one line each. The prefix makes them text records, and the
RFC lets a command document its own layout; scripts that parse them keep
working.
- commands, plugins, parity and the .scraft summary of info are text and get
no --json flag (an open question in the RFC); mcp speaks JSON-RPC lines.
Pretty output is byte-identical to before.
* fix(app): one Switch parser for boolean environment switches
SOUNDCRAFT_NO_PREFS, SOUNDCRAFT_NO_SYSTEM_FONTS and SOUNDCRAFT_WAYLAND each
counted any value, including 0, as on. They now share
soundcraft_ui_egui::switch::Switch: 1/on/true/yes is On, 0/off/false/no is
Off (case- and space-insensitive), unset or blank is Default, and any other
value is Default and logged with the variable name. The ENVIRONMENT help
text, docs/cli.md and the README say the same.
Behaviour changes (old -> new):
- SOUNDCRAFT_NO_PREFS=0 disabled preferences (any value did); now only On
does.
- SOUNDCRAFT_NO_SYSTEM_FONTS=0 disabled system fonts (any value did); now
only On does.
- SOUNDCRAFT_WAYLAND=0 kept native Wayland (any value did); now only On does.
- An empty value of any of the three counted as set; it now means the
default (off).
- An unknown value of any of the three is logged.
* refactor(app): read the environment once into Env
The desktop app reads SOUNDCRAFT_CONFIG_DIR, SOUNDCRAFT_NO_PREFS,
SOUNDCRAFT_NO_SYSTEM_FONTS and SOUNDCRAFT_WAYLAND once at startup into
env::Env (typed fields, Switch for the on/off ones), after the logger is
installed so malformed switches are logged. The --help Environment
section is generated from the same VARS table, with a test that it lists
every name.
ui-egui no longer reads the app's variables: fonts::install and
definitions take system_fonts, set through SoundApp::set_system_fonts
(default on, so the web app is unchanged). The XWayland and prefs
decisions are methods on Env, tested on literals. logging::install takes
the RUST_LOG value, which stays outside Env because the logger needs it
before anything can log.
No behaviour change beyond help column spacing.
* refactor(cli): one long_version for both binaries
* refactor(audio-io): one FileFormat table for extensions
FileFormat gains the extension table every caller matches on:
from_extension / from_path (ASCII case-insensitive) over extensions,
from_write_extension over write_extensions, default_extension, name,
mime_type, the READ and WRITE lists, and readable_extensions /
writable_extensions for dialog filters, help and messages. Reading
takes every extension the decoder knows; writing takes only the names
whose readers expect what the encoder writes (no rf64 or aifc, whose
files would get plain RIFF and AIFF headers).
detect_format, bounce (mix and stems), convert, the bounce dialog, the
desktop and web file pickers, the workspace browser, the Sampler, web
downloads and the MCP bounce schema all use it; ENCODE_EXTENSIONS and
encode_format_for are gone. convert's IN/OUT and info's FILE help name
the extensions from the read and write lists.
Behaviour changes, where the lists disagreed:
- The bounce dialog's Browse result, the bounce save filter and the MCP
bounce enum take wav, wave, bwf, aif, aiff and flac: wave and bwf are
new to all three, and aif is new to the MCP enum. convert and
file.bounce_mix already took these. The "supported:" list in the
cannot-write errors is "wav, wave, bwf, aif, aiff, flac".
- file.bounce_stems reads its format name case-insensitively: `AIFF`
or `FLAC` meant WAV before. Unknown names still mean WAV.
- The bounce dialog sends the format as its file extension (aif for
AIFF); the engine accepts both spellings.
- File pickers, the workspace browser and the Sampler's drop and Load
Sample filter list every extension the decoder reads: the workspace
browser gains wave, bwf, rf64, aifc, mp1, mp2, oga, aac, mp4, adts,
mkv and webm; the Sampler gains rf64, mp1, mp2, m4b, adts, mkv and
webm; the desktop and web open filters gain rf64, mp1, mp2 and adts.
- Web downloads get a media type for every audio format, not only WAV,
AIFF and FLAC.
* refactor(app): resolve the settings folder once in Env
* fix(app): keep paths as paths until the engine needs text
A path is text only where an engine command takes `"path": string`; a
name that is not valid Unicode is reported there instead of being opened
under a lossy rendering (another file, or a confusing "can't read").
- ui-egui: dialogs::unicode() and NotUnicode ("<path:?> is not valid
Unicode"); dialogs::open_path(app, &Path), moved from the desktop app,
is the one open-by-extension path for dropped, command-line and
Workspace files.
- Services: the file dialogs return PathBuf (pick_save and pick_folder
start from a &Path); callers convert where the text field or command
takes the path.
- Sampler drops and Load Sample pass the path to mix.sampler_load
through unicode(); the audio test checks the Path.
- Workspace panel keeps entry paths; names are shown lossily only as
labels. workspace_dir stays a String (serialized preferences).
- soundcraft-cli: utf8() is unicode(), CliError::NonUnicodePath is
NotUnicode; it is called only for info's session, --in, --bounce and
--save. convert's output format test no longer treats a non-Unicode
extension as "no extension".
- docs/cli.md: sessions and bounces the engine opens or writes must be
valid Unicode.
Behaviour changes:
- A dropped, command-line, picked or Workspace file whose name is not
valid Unicode is not opened: the status line (and log) says
"<path:?> is not valid Unicode", with the bytes escaped. Dropped files
used to be opened under a U+FFFD rendering.
- A file dialog result that is not valid Unicode leaves the path field
unchanged and shows the same warning.
- Double-clicking a Workspace file opens it like a dropped file:
.MID/.MIDI/.SCRAFT in any case open as MIDI/session (they used to
import as audio), and a failed open is logged.
- convert OUT with an extension that is not valid Unicode is refused as
an unsupported format instead of being written as WAV.
* fix(app): read Env after the launch flags; tidy Switch and Env
* fix(audio-io): decode every extension the format table lists
* fix(cli): write convert output atomically; tighten --connect and loopback
- convert writes every output to a new file in the output's folder, flushes it and renames it
over the target, removing the new file on failure. A failed write (disk full, unwritable
folder) no longer truncates the input under --in-place (RFC-0012 rule 1.6). A link output
still writes the linked file.
- --save in place already goes through the engine's replace_file (tmp + rename), and
file.bounce_mix renders to <name>.part and renames it when complete, so both are atomic.
- The desktop's --control / SOUNDCRAFT_CONTROL_PORT no longer treat [::1] as loopback: the
control server binds 127.0.0.1 only. [::1]:P is a usage error from the flag and is ignored
with a warning from the variable. The CLI's --connect / app --port still accept [::1]:P.
Error tip and docs/cli.md updated.
- mcp --connect conflicts with --demo (exit 2): the demo session belongs to the in-process
server. Help and docs/cli.md say so.
* test(audio-io): list the decode fixtures in ATTRIBUTION.md
cargo xtask assets needs a row for every committed audio file; one
directory row covers crates/audio-io/tests/fixtures/.
* fix(cli): refuse non-finite --start and --end values
* refactor(cli): parse the control target once; open sessions by path; keep error sources
- A1: ControlTarget { host, port } is parsed once by args::control_target; is_loopback() replaces
control_port/ControlPort, and Remote::new takes &ControlTarget.
- A2: engine::io::open_session takes impl AsRef<Path>; the CLI no longer requires a Unicode path
to open a session, only for the --bounce and --save paths that go into JSON.
- A3: ValueError::Json keeps the serde_json::Error; Remote::request returns RemoteError and
AutomationRoots::new returns RootError, both carried by CliError.
* test(engine): pass session paths to open_session without a borrow
* test(cli): info opens a session at a non-Unicode path
* refactor(app): log the shared long version; info: session extension in any case; Switch beside Env
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.
The exact bit and file-hash pins were taken on one machine and failed
in CI, where the platform maths library rounds differently in the last
bit. Levels are now checked within 1e-3 dB of the pinned values, and
the maximizer and plate reverb tests check that the output does not
depend on the block size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Profiling a demo-session bounce showed the time going to per-sample work that
can be done more cheaply while producing bit-identical results:
- mix: the fader ramp converted dB to gain (two powf) for every sample of every
strip and of the master; GainRamp converts once per block.
- mix: apply_matrix_span has slice fast paths for one and two taps per output,
summing in the same order as the general loop.
- mix/dsp: strip meters and the bounce peak use an 8-lane max (channel_peak);
max is order-independent, so the result is unchanged.
- dsp: LoudnessMeter true peak keeps its history in a doubled ring instead of
shifting it every sample and runs the four phases side by side, each phase
still summing its taps in the original order.
- dsp: Maximizer finds the window minimum with a monotonic queue instead of
scanning the whole lookahead window every sample.
- dsp: flush() tests both bounds without short-circuiting; audio-io quantize
rounds half away from zero without the libm call (exact on its clamped range).
Demo bounce (24-bit dithered WAV, 55.9 s, 3 bounces per run, interleaved,
15 runs each, Ryzen 16 threads, shared machine): median 3.677 s -> 2.955 s
(-19.6 %), min 3.116 s -> 2.394 s (-23.2 %). Output WAV byte-identical, reported
LUFS/true peak/peak identical. New tests pin meter, maximizer, plate reverb and
demo bounce output against values taken from the previous implementation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
examples/bounce_bench drives file.bounce_mix on the built-in demo session (the same path the
Bounce dialog and soundcraft-cli --bounce use) and prints per-run wall time, so export speed can
be measured and compared between builds:
cargo run --release -p soundcraft-engine --example bounce_bench -- OUT.wav [reps] [JSON params]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Real msiexec runs (per-user and all-users, silent and wizard) found two defects
that the metadata fixtures cannot see:
- "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as
per-machine and skipped the existing per-user copy. An interactive upgrade
left two registered products and uninstall left files behind. The page now
leaves ALLUSERS unset for a per-user choice.
- Windows Installer resolves Program Files once, for the context it started in,
so choosing "Everyone" in the wizard installed into the user profile with a
machine-wide registration. The wizard now sets the new-install folder itself
after the choice (Program Files, Program Files (x86) for 32-bit, or the
per-user Programs folder). Silent installs are unchanged.
check-install-policy.ps1 now fails if either regresses.
* feat(ui): use craft-libs' ComboBox so menus grow back, and its menu for the send destination list
Repin craft-libs to 62bb0e1d (includes craft-libs#384 and #387). Every egui::ComboBox
becomes craft_ui::ComboBox, and clippy.toml disallows the egui one. The send destination
menu moves to craft_ui::combo::Menu with the title and search in the header and
"new bus..." / "Remove send" in a footer.
* chore(deps): craft-libs 6af63481 (dropdown fixes)
* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)
* chore(deps): craft-libs 92d392cf (#387 merged)
* ui: keep SoundCraft's beside-the-session-view place for panels craft-ui has no record of
* ui: Tracks and Groups and Clips in full-height dock columns
The Edit window docks Tracks and Groups (168 pt) at its start edge and Clips
(230 pt) at its end edge, with the MIDI editor under the session view between
them; Mix docks Tracks and Groups at its start edge. Panels dragged to an
empty window edge make a dock there.
Saved preferences and window configurations carry docking_version. Split-frame
layouts saved without it load as the new defaults, keeping every other
setting. A list with no recorded place reopens in its dock group, making the
dock again at its side and width when it is gone.
Pins craft-ui, craft-docpath and craft-media to craft-libs#312.
* Pin craft-libs at d24e4bc for the Mac round 3
* ui: test that a floating list reopens floating while its dock is shown; ui-parity revision
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.
Part of tracking issue storytold/craft-libs#319
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.
Part of tracking issue storytold/craft-libs#319🤖 Generated with Codebuff
Co-authored-by: Codebuff <noreply@codebuff.com>
Picks up the fix that keeps the close request pending while the app holds unsaved work, the executable-name check in the Windows update helper, and the zip 9 / dirs-free build.
Part of tracking issue storytold/craft-libs#319
* feat: startup update prompt modal and direct self-updating
Integrate `craft-update` pinned to storytold/craft-libs fe61243 to check for
updates on startup, display the standard suite update modal with changelog notes,
and support one-click in-app updating and restart.
Ref: storytold/craft-libs#319
* wip
* build: refresh the lock
* refactor(updates): drive the flow with craft-update's Updater
The check at start is opt-in for the desktop app, a successful update closes through the app's own close path, and no update starts over unsaved work.
* fix(updates): register the update command and hold the install back during a recording
Help ▸ Check for Updates… goes through the shared command id, and an active recording counts as work the update must not interrupt.
* Auto Pan: balance the left and right channels at constant power instead of panning their mono sum
* Strip Silence rectangles: on the web, rescan when a slider drag ends instead of on every frame of it
* Paste: keep the clipboard's remapped Auto Rename keys only when the paste succeeds
* Subharmonic: test that Q 5 filters after the saturator keep the output finite
* AudioSuite window: drop the constant-speed varispeed controls no menu item opens
* docs: gaps: Clip Looping has no loop crossfade and caps Number of Loops at 512
#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>
When a strip's channel count changes, the first-two-channel peaks now take the new values
along with the per-channel ones. A take that finds no new block still drops the meters of
deleted tracks from the UI's copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
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.
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.
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.
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.
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.
* 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
The audio thread overwrote the shared meter snapshot every block (~94 blocks/s at 512
frames, 48 kHz) while the UI read it 20-60 times a second, so it only ever saw the latest
block and short transients were under-read. Blocks are now folded together (max of each
channel's peak and of the gain reduction; plugin failures keep the latest count) until the UI
takes them with Player::take_meters, which also starts a new period. Blocks rendered while the
UI holds the lock wait in an audio-side accumulator instead of being lost; the audio thread
still only try_locks.
Stopping, starting and locating now zero the meters in place instead of clearing the maps,
so playing again no longer re-allocates every track's entry on the audio thread. The UI
thread works out which tracks a session update deleted and tells the audio thread, which
drops just their entries.
The Mix window and the Renderer now read the snapshot the app takes once per poll instead of
reading the player themselves, so a period's peaks are not split between readers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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>
#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>
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
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>
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>
* 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
* 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>
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.
* 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.
* 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.
* 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
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).
* 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
* 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).
* 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.
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.
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>
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>
`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.
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.
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.
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.
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.
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>
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.
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.
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.
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>
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.
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.
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).
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.
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.
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>
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.
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>
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>
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>
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>
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.
`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>
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.
craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to
storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07,
which predates resolve_absolute and the Windows colon refusal.
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>
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>
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>
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>
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>
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>
decode_frame_into refuses a frame header larger than the movie picture
limit before sizing any plane, and Movie refuses a ProRes frame whose
header size differs from the track's declared size before decoding it.
Audio source and movie paths stored in a .scraft session go through
io::stored_path (craft-docpath) before they reach the file system: a
relative path is taken from the session's folder, and paths on other
computers, devices and URLs are refused. A refused source is reported
as missing with the reason; a refused movie is marked offline and the
Video track and Video window show why instead of opening it. Import
Session Data and crash recovery resolve the same way.
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>
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>
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>
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.
`cargo xtask ci` failed on Windows before any test ran: the `windows_ignores_case_and_refuses_devices` test moved `upper` into `check` and borrowed it afterwards for the assertion message (E0382), and the recovery test's `paths` is only mutated inside its `#[cfg(unix)]` symlink block, so `-D warnings` rejected the `mut` everywhere else. Neither test is compiled on macOS or Linux, which is why CI never saw them.
With this, `cargo xtask ci` passes on Windows 11 (MSVC, Rust 1.99).
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.
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>
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>
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>
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>
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>
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>
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.
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>
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.
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>
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>
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.
The FreeBSD CI VM has no fonts installed, so the "bold" family falls back
to egui's built-in regular face. SENDS A-E and SENDS F-J then measure 47
and 45 pt, inside the 48 pt the measured-fit check allows, so narrow
strips showed them in full next to I A-E and INST, and
mix_narrow_headers failed on FreeBSD.
Narrow strips now always use Pro Tools' short names, whatever the font;
normal strips still shorten a name only when it doesn't fit. A new test
renders the narrow Mix with egui's built-in fonts only, reproducing the
FreeBSD failure on any platform.
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.
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.
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.
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
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.
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.
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.
Reject oversized batches before any side effects instead of reporting success after truncating calls.
Closes#460
Co-Authored-By: OpenAI Codex <noreply@openai.com>
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.
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.
`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>
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>
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>
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>
No UI font family had Hangul, so Korean session, file, track and clip names drew as boxes in every interface language. Following the guidance on #141 (system fonts at runtime, nothing bundled), borrow an installed Korean face (fontconfig lang=ko, Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR, NanumGothic) and append it to every family.
The landing branch already syncs host-set values to the controller before
saving state (lock-free atomics + EditorLink::sync_param); the PR's later
ControllerPending table did the same, so keep one. Keeps the PR's new
'a loaded state replaces earlier edits' assertion, which passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#309 and this branch both added io::read_file and read_text. There is
one of each now: read_file checks the automation read root (#309), then
refuses anything but a regular file (this branch), and read_text is
read_file as UTF-8. Opening a session keeps #309's explicit read-root
check, so a refused path is reported with its reason, then decodes
through this branch's decode_file. The clip group importer and
decode_file take their de-duplication key from a new io::file_key,
since #309's test keeps std::fs calls inside io.rs. Session saves use
#309's make_dir. Import Session Data keeps decoding through
decode_file, so the load_source that #309 had switched to read_file
stays removed.
#304's MIDI Track Offset shifts the block schedule_notes reads, which
now carries this branch's controllers too. chase_controllers takes the
same offset, so a seek chases the controller values in effect where the
offset puts the track, not at the raw transport position. New test: with
the track 0.5 s late, starting at 1.25 s chases the sustain pedal still
down (without the offset it sent the pedal-up that only plays at 1.5 s).
Also: reset() sets the chase flag beside #301's alignment flush, the
VST3 Instance keeps #400's set_normalized beside midi_mapping, and the
mix tests keep #210's Keys plugin beside this branch's Clock and Ears.
Timecode::to_frames takes main's i128 version (#260), which already
covers the overflow this branch guarded with saturating arithmetic.
The Spot dialog formats and parses through Session::format_position
and parse_position, so it also gets #294's feet+frames start. Nudge
keeps #363's `next` parameter beside this branch's Edit Mode, Snap to
Grid and Shuffle Lock commands. The mode tooltips use main's `fkey`.
Main's track rows (#382 tint, #383 headers, #359 grid over clips) take
this branch's borrowed track and shared session snapshot. The ruler
labels use main's format_position with the feet+frames start, but keep
the frame rate timebase_ruler is given, so the second timecode ruler
still shows its own rate without a session copy.
#309 confines file writes to the automation write root: make_dir,
write_path and replace_file now check every path first. This branch
bounces through Bounce, which streams to a .part file and renames it
over the target, so Bounce::new now checks both paths before it creates
the part file. That covers Bounce Mix, bus bounces and stems, which
main writes through write_file and write_path. Stems keep the 4-hour
limit and use make_dir. replace_file keeps #309's checks in front of
this branch's replace_with. automation_roots' bounce cases pass.
#400 (fixes#257) made Vst3Plugin::save_state tell the edit controller
the values the host set before taking the snapshot. This branch reads
plugin state on the UI thread through EditorLink instead, and removes
Vst3Plugin::save_state, so keeping its side of that conflict would have
dropped the fix.
The values now wait in a ControllerPending table shared by the instance
and its EditorLink: one AtomicU64 slot per parameter, which set_param
fills on the audio thread without locking or allocating.
EditorLink::save_state sends the waiting values with setParamNormalized
under its lock, before it reads the controller's state. A state load
clears them, as #400's refresh did. Instance::set_normalized, which only
the old save_state used, is gone. #400's round-trip test passes, plus a
check that a loaded state replaces earlier host edits.
#327's a_new_instance_replaces_a_matching_one_in_its_slot told the two
instances apart through MixEngine::capture_states, which this branch
removes (states are read on the UI thread). The two instances now
differ in gain instead (-6 and -20 dB), and the test checks that the new
level plays and that the old instance is retired.
Also: Side::new sets #210's old_sessions field.
Press a checkbox and drag over its neighbours: each box the pointer
passes takes the state the first one switched to, until the button is
released. A click toggles one box, as before. Same design as VectorCraft
and PhotoCraft.
widgets::checkbox wraps egui's checkbox and adds the drag; every
ui.checkbox call in ui-egui uses it. Only boxes the pointer can see are
set, so rows scrolled out of a list or under another window stay as
they are.
Tests: widgets::tests (drag sets, drag unchecks and stops at release,
hidden rows untouched, a click toggles one) and tests/group_dialog.rs
(dragging down the Group dialog's track list groups every track).
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>
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>
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>
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>
mix.instrument only read `plugin` and `track` and ignored every other field, so a
synth setting written beside `plugin` (cutoff, amp_sustain, level…) came back
as success and the render never changed.
It now takes the built-in instrument's parameters in `params`, validated before
anything is changed (unknown ids and non-numbers are errors, values are
clamped into range), and refuses any other field with a message that points to
`params` and mix.instrument_param.
The master faders process the main mix after every track and aux return has been
summed, so a lookahead insert on them (the Maximizer: 71 samples at 48 kHz)
delays the whole main mix. The mix's compensation latency, which a bounce drops
from the head of the render, left it out, so the bounce came out late by the
insert's latency (the voice was 71 samples later than in the same mix without
the Maximizer).
The latency of the master inserts is now part of the mix latency. With Delay
Compensation off nothing changes.
render_bus dropped only the track-insert latency from a bus, but an aux that
returns onto a bus delays it by the slowest aux return as well. With Delay
Compensation on, a bounce of such a bus came out late (a 5 ms lookahead left
240 leading silent frames) and its last impulse fell outside the requested end.
The engine now knows which busses an aux returns onto. A bounce of one drops
the aux latency too, and the tracks routed straight to that bus wait for it, as
the tracks routed to the main mix already do, so the two kinds of signal on the
bus arrive together. Other busses and the main mix are unchanged.
Both gained a UI path from PRs that landed today, so the reachability
ratchet from #358 now fails on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ui.audio_device and Playback Engine › Apply wrote ui.audio before
apply_audio_config could refuse (while recording), so the saved choice no
longer matched the open device. apply_audio_config now takes the new
configuration and stores it only after the refusal check. Adds a test for
ui.audio_device: no-op query, a bad rate, refusal while recording, and an
applied change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
window.mix_separate toggles (and takes a value), Toggle Mix/Edit keeps Edit
in the main window while Mix is separate, a few frames draw both, and
closing the Mix window brings Mix back into the main one. The close handling
moves into mix_window::separate_closed so the test can reach it.
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>
It cut the new clip at MAX_RENDER_SAMPLES / channels without a word, after
allocating up to 2 GiB for it. Refuse such a selection up front, as #326
does for other renders, before anything is generated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A relative session path has an empty parent, which made the default
"/<name> Bounce.wav". Ignore an empty parent so it falls back to the
default folder, as for a session never saved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
recording.json names the take files whose headers recovery rewrites. Before
writing, check that each is a regular file (not a symlink) directly in the
Audio Files folder a recording of that session writes into (next to the
session file, or in its folder under the autosave root) and that it starts
with the float WAV header the recorder writes. Anything else is left
untouched, logged and listed with the media recovery couldn't load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
schedule_notes ran format!() every block for every instrument track; scan
the edit values by prefix instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
clip.unloop found loop copies only by comparing content of back-to-back
clips. As soon as the source clip's notes were edited, its content no
longer matched the copies and unloop answered "the selection is not a
looped clip" for the very loop that clip.loop had just created.
clip.loop now tags the origin and its copies with a shared `loop_id`
(new optional Clip field, serde default so old sessions load), and
unloop treats equal-length adjacent clips with the same loop_id as one
loop. The content comparison stays as a fallback for loops without a
tag. unloop with mode=flatten clears the tag so the copies become
ordinary independent clips.
Propagating edits from the source to the copies is not part of this
change.
Plugin::set_param only queues the value for the processor, so a parameter that lives in the controller alone (no processor state) was saved with the controller's old value and came back wrong after load_state. set_param may run on the audio thread, so it now just marks the value; save_state, which already calls into the controller, pushes the marked values with setParamNormalized before taking the snapshot.
A MIDI clip whose start was trimmed may keep the events before its new
start at negative ticks, hidden until the start is trimmed out again
(midi-clip-trim does this). The controller loop clamped every event's
tick to 0, so a hidden sustain pedal or pitch bend played at the clip
start, and the chase after a seek counted hidden values as the state
in effect.
Both loops now skip events before tick 0. Nothing changes for clips
without hidden events: notes and controllers made by the app never
start before tick 0.
Test: a sustain-pedal event at tick -480 is neither sent when the clip
starts nor chased after a seek into the clip, while a visible
controller is still chased.
A narrow Mix strip's sections are 56 pt wide, and INSERTS A-E (63 pt),
INSERTS F-J, SENDS A-E, SENDS F-J and INSTRUMENT (67 pt) ran into the
neighbouring strips. Pro Tools' Narrow Mix shortens them instead.
- A section name that doesn't fit with 4 pt to spare on each side uses
Pro Tools' short form: I A-E, I F-J, S A-E, S F-J, and INST.
- The group selector says "nogrp", as Pro Tools' does, instead of
"no gr…".
- Narrow strips leave the empty comments box without its hint, which
wrapped as "comme / nts".
Normal strips are unchanged. tests/mix_narrow_headers.rs checks both
widths, including that no label in a narrow strip is cut short; it
fails on main.
Pro Tools draws a strip's fader and meter as one unit: the meter's dB
scale sits between them, the meter spans the fader's travel, and its
bar is a solid column over dark tints of its zones. Ours had LED
stripes, no meter scale, and a meter taller than the fader's travel.
- Meters: a solid bar that lights green, yellow and red over dark tints
of those zones (fainter in light wells), ruled at the scale's marks,
with a clip light above the bar and a 2 pt peak hold in its zone's
colour. This is the shared meter, so the Edit window, toolbar and
transport meters change too.
- Mix strips: the meter runs the fader's travel, 0 dBFS level with
+12, and its 0-60 dB scale sits between the fader and the meter.
Narrow strips show the fader's ticks without numbers, as Pro Tools'
Narrow Mix does.
- Faders: a narrower, taller cap (18 x 40 pt, 16 pt wide in narrow
strips) with a pinched waist and a dark centre line, in a 4 pt slot.
The scale's numbers sit beside the cap and skip marks too close to
the one above.
tests/mix_strip_layout.rs checks that the meter scale's 0 is level
with the fader's 12 and that narrow strips draw no scale numbers; both
fail before this change.
Part of #138.
detect_transients (Tab to Transient, Separate at Transients, Beat Detective)
missed most onsets that weren't the loudest around, and put notes that start
over a ringing note 25-35 ms early, in the previous note's tail. Its
threshold was a share of the loudest flux in the analysed audio, so quiet
hits vanished next to loud ones and the result changed with where the
analysis started. It placed each onset on the first sample reaching a
quarter of the frame's peak, which a ringing note already does.
- Vibrato-suppressed spectral flux: 21 ms windows 5.3 ms apart, log
magnitudes on 24 bands per octave from 30 Hz to 20 kHz, each band compared
with the loudest of its neighbours in the frame before, so vibrato and
chorus don't count. Channels are analysed as the sum of their energies,
so stereo content that cancels in mono still counts.
- A peak must be the highest within 30 ms and exceed the mean flux from
100 ms before to 70 ms after by a fixed amount (2.5 at sensitivity 0.5),
so quiet hits count and the result doesn't depend on where the audio
starts.
- A fast release smears energy between a note's partials, which looks like
a rise. A detection also needs a 3 dB step in its high-passed energy, or
total energy that grows in its frame or the next.
- Each onset goes to the step edge of the high-passed energy (the sample
where the next 3 ms gain most over the 6 ms before), then back to where
the energy crosses the midpoint between the two: 0.3-0.5 ms before
percussive attacks.
On 29 synthetic cases with known onsets (drums with ghost notes; drum-synth
grooves dry and through reverb, compression and limiting; 180 bpm; flams and
claps; legato piano; plucked bass and guitar; strums; 808 kicks; hand drums;
synth bass, leads, pads and staccato notes; string swells; a vibrato pad;
quiet hits over noise and hum; stereo pairs; formant syllables), the
F-measure went from 0.658 to 0.977. It found 1398 onsets instead of 870,
with 5 false ones instead of 315; all 5 are vowels starting after a
consonant. Still missed: hats under a crash, ghost notes under a louder
hit's tail, and slow string swells.
The old offline_meters test bursts stopped dead at -37 dB, an audible click
that now counts as a transient; they ring out instead.
Tab found the next transient only on the first selected track, analysed
everything from the cursor to the end of the track on every press, and
Alt+Tab, Shift+Tab and Alt+Shift+Tab ignored Tab to Transient (Shift+Tab
did nothing at all). The toolbar button used the Selector tool's icon.
- A new command, edit.tab {back?, extend?}, does what the Tab keys do, as in
Pro Tools: Tab and Alt+Tab (Option+Tab on a Mac, Ctrl+Tab on Windows and
Linux) move the insertion point to the next or previous clip start, end or
sync point on the selected tracks; Shift+Tab and Alt+Shift+Tab
(Ctrl+Shift+Tab) extend the selection. Alt+Shift+Tab pulls the selection
end back while a stop lies inside the selection, then grows its start.
- With Tab to Transient on, the stops also include the transients on every
selected audio track and the note-ons and note-offs on MIDI and Instrument
tracks. A transient within 10 ms of a clip boundary or note is that stop,
not a second one.
- Transients are detected in fixed 10-second blocks of each clip, so Tab and
Alt+Tab always find the same ones and a long clip costs one block per
press. Analysed blocks are cached while their clips, clip effects and audio
are unchanged: 0.5 ms a press across the demo's five audio tracks, 43 ms
without the cache.
- The Tab to Transient button gets its own icon, an arrow into a transient.
Cmd+Alt+Tab still toggles Tab to Transient. On Windows and Linux the desktop
takes Ctrl+Alt+Tab, and Pro Tools' Ctrl+Alt+T is Thin Automation here too, so
the button and the Options menu are the way there.
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.
CLAP and VST3 plugins report their latency unchecked (up to u32::MAX
samples), and an offline render skips that many samples before its first
block, inside one call. A plugin reporting garbage made a bounce render
about 25 hours of audio before producing anything, with the UI stuck in
that call: no progress, no Cancel. Freeze and Commit stalled the same
way.
- A strip reports at most 2^20 samples of latency (about 22 s at 48 kHz),
the most delay compensation could align anyway (MAX_LATENCY, shared
with the delay lines). That holds for the latency an aux's inserts
report before the aux is processed, too.
- RangeRender::next_block renders one block per call, returning no frames
while it skips the latency, so a bounce yields to the UI there too.
Bounce Mix rendered the whole file inside one UI frame: the window stopped
drawing and nothing could cancel the bounce until the file was written,
which takes minutes for long or plugin-heavy sessions.
- engine: io::Bounce renders a slice at a time (step) and reports its
progress; dropping an unfinished one cancels it, removing the partial
file and keeping any older one. cmd::bounce_job builds it from
file.bounce_mix's params; the command and stems still run it to
completion.
- ui: the Bounce dialog starts the job and logic() renders it for 30 ms
per call (32 blocks in the browser, where Instant panics), requesting
another call each time, so it carries on while the window is hidden
(eframe then runs logic alone). A modal shows the file, a progress bar
and Cancel (or Esc); the status line reports the result, and the Task
Manager lists the running bounce.
- It stays on the UI thread because hosted plugins must be created there.
A bounce rendered the whole mix (or bus) into memory and then encoded a
second copy, and the render silently stopped at 2^29 samples: about 93
minutes of stereo at 48 kHz, 31 of 5.1, 15 of 7.1.4. Longer bounces came
out cut short while file.bounce_mix still reported the requested length.
- audio-io: FileEncoder writes WAV/BWF (RF64 above 4 GiB), AIFF and FLAC
block by block into any Write + Seek, byte for byte what encode writes
for the whole buffer. check_room fails up front when an AIFF (4 GiB) or
a FLAC (2^36 frames) can't hold the bounce. AIFF and FLAC encode run on
the same writers; their output is unchanged.
- mix: RangeRender renders the main mix or one bus over a range of any
length, a block at a time; render_range and render_bus (in memory,
still capped) are built on it.
- engine: io::Bounce streams the main mix or a bus, or its stereo
fold-down, into "<name>.part" next to the target, syncs it and renames
it into place, so a failed bounce leaves no partial file and keeps the
old one. In the browser it encodes into memory (failing instead of
aborting when memory runs out) and delivers the file as before.
Normalize renders twice (peak, then the scaled write); levels are
measured as blocks are written. Stems stream the same way and share the
4-hour limit.
A 100-minute bounce of the demo session now writes all 288,000,000
frames with 83 MB peak memory.
Pro Tools draws a take's waveform as it records; ours showed nothing
until Stop. Each record-enabled track now shows its take growing as a
red clip with its waveform, drawn from the take's live summary through
the same column grid and channel bands as clips (Peak view, as Pro Tools
always shows while recording).
- The take writer keeps, per take channel, the (min, max) of every 128
frames it writes (at most 2^22 blocks, three hours at 48 kHz; the file
records on regardless). Recorder::live_take hands the Edit window what
a track's take holds so far, while the writer runs. The blocks sit
behind a mutex the writer thread and the UI lock briefly; the input
callback is untouched.
- io::live_span places the take as Stop will: from the record start,
only the pass under way when loop recording, and within the punch
range.
Tests: the take writer's summary (record.rs), live_span with loop and
punch (io.rs) and draw_live (tests/waveform.rs).
Double-clicking a MIDI clip in the Edit window only selected it, so the
MIDI Editor had to be opened from Window › MIDI Editor or the dock tabs.
In Pro Tools a Grabber double-click on a MIDI clip opens it in a MIDI
Editor (the "Double-Clicking a MIDI Clip Opens" MIDI preference, which
defaults to an editor); SoundCraft has one, docked, editor.
- A double-click with the Grabber on a MIDI or instrument clip selects
the clip, as before, and opens the docked MIDI Editor on it. An editor
that's already open stays open and switches to the clip.
- The Smart Tool follows its zones: its lower half is the Grabber and
opens the editor; its upper half is the Selector, where a double-click
still only selects the clip, as in Pro Tools.
- Audio clips are unchanged: a double-click selects them.
Tests: a Grabber double-click opens the editor on the clip; with the
Smart Tool, the upper half selects without opening it and the lower half
opens it; an audio clip leaves the editor closed. The first two fail on
main.
A device or driver that sends NaN or infinite samples got them written
into the take, where they poison whatever the clip is mixed with, and
into the input monitor. Both paths now turn them into silence before
they are queued, so a take keeps its length and timing.
Takes stream to disk while recording, but they only join the session at
Stop. After a crash the files were on disk with nothing pointing at
them and no record of where they started, and the header of each was up
to a second behind its audio.
When recording starts, its plan (each track's take file, where the
takes start, the loop length and punch range) is autosaved with the
session, and every autosave until Stop keeps it there. Stop places the
takes from that same plan. Recovering a folder that still holds a plan
first repairs each take's header to cover all of its audio, then places
the takes as Stop would have, skipping any the session already has. The
recovery window says when there's an unfinished recording, and such a
folder is kept even if its session file was saved later.
Opening, creating, closing or reverting a session in the middle of a
take dropped the recorder: the take was written to disk but never added
to any session. Quitting mid-take did the same, and on_exit didn't
finish the take either.
New, Open, Close, Revert and Quit now wait for the take: the engine
refuses them with "stop recording first", and their menu items are
greyed out while recording, as is Recover in the recovery window. Any
exit that still happens mid-take (closing the window, the system
quitting the app, a remote quit) finishes the take first and keeps the
session, take included, in the autosave folder for the next launch.
Losing the output device only reached the log: the transport kept
"playing" a frozen playhead, and a recording ran on with nobody told.
The output now reports trouble once: the device was disconnected, or it
stopped asking for audio for two seconds while playing (not checked in
the web build, which has no clock for it), or the engine hit a bug as
before. The app stops the transport, keeps a take in progress, and says
what happened and how to reconnect.
A failing input only reached the log. When the interface was unplugged
mid-take (on macOS its callbacks simply stop), when writing a take
failed (a full disk), or when the input callback panicked, recording
went on with the transport running and the take silently ended early.
A panic in the callback could also take the input stream down for good.
The recorder now reports trouble: the device was disconnected, it sent
nothing for two seconds while recording, its callback hit a bug (caught,
after which it does nothing more), or writing a take failed. The app
stops recording at once, keeps the take up to that point and says what
happened and what to do. Input lost to a slow disk is reported while
recording instead of only at Stop. Reconnect audio device now reopens
the input as well as the output, so monitoring works again afterwards.
Autosave ran every two minutes and not at all while recording, so a
crash could lose minutes of work. It also wrote on the UI thread: the
first autosave after a recording copied the take, which could freeze
the window for seconds.
The open session now goes to a writer thread after every change,
recording or not. The first change after a pause is written at once;
while changes keep coming, as in a fader drag, the newest is written at
most once a second. The UI waits for the writer only where it needs the
result: after a caught panic, when recovering and when quitting. A bug
in a write is caught and reported, and the next change tries again.
Plugins' own state still reaches the session every two minutes and not
while recording, since plugins save it on the audio thread.
Nothing caught a panic in the audio output callback. On macOS it
unwinds into CoreAudio's thread, which most likely aborts the app; on
Windows and Linux the stream thread dies while the transport keeps
showing playback with no sound. A panic in the silent clock that runs
without an audio device stopped the clock for good.
Each block is now filled under a guard. A panic makes that block and
every later one silent and marks the engine failed. The app then stops
the transport, keeping what was recorded, and says to reconnect the
audio device or restart.
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>
With no widget focused, egui answers Tab by giving keyboard focus to the
first widget in the window, and Shift+Tab to the last one. shortcuts::handle
ignores every key while a widget has focus, so after Tab (to the next clip
boundary) R/T zoom and every other shortcut did nothing until a click
elsewhere or Escape. Space and Enter clicked the focused widget instead: on
macOS that is the toolbar's Shuffle button, so Space switched the session to
Shuffle; on Windows and Linux it opened the SoundCraft menu.
While the shortcuts own the keyboard (nothing focused, no dialog open),
handle() now cancels egui's focus move for the frame. Tab still moves between
fields once one has focus, and inside dialogs.
set_meter and remove_meter now re-place every later meter event at the
bar number it started before the edit, so meter changes stay on bar
lines and bar|beat positions round-trip. Clips, notes and tempo events
keep their positions.
Fixes#250
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shelf corner (~1682 Hz) sits at or above Nyquist for rates under
~3.4 kHz. The bilinear prewarp tan() then wraps negative (unstable
poles, +750 LUFS readings, -144 integrated) or aliases. Hold the
design frequency below Nyquist (0.49 * sr, as Coeffs::design does).
Coefficients at 8 kHz and above are unchanged.
Fixes#240
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dragging a track's bottom edge showed nothing until release; then the
track jumped to the nearest height preset. Pro Tools' track height
modifiers and keys were missing too.
Dragging:
- The edge follows the pointer as in Pro Tools: the track steps through
Micro…Extreme while the pointer moves, and the tracks below move with
it. The whole drag is one undo step (execute_merged, ended on release
like fader moves).
- Cmd (Ctrl on Windows and Linux) drags to any height from Micro to
Extreme instead of snapping. The model gains TrackHeight::Custom
(points), and track.height takes a number of points as well as a
preset name.
- Alt resizes every track the Edit window shows; Alt+Shift resizes the
selected tracks too. The track menu's Track Height items follow the
same rule.
- The drag is measured from where it started, not from the edge's
current place: when the tracks above the dragged one resize they move
its edge, and edge-relative math flipped between two heights on every
frame. This also fixes rows showing playlist lanes, which a plain
click on the edge used to make taller.
Keys:
- New commands track.height_up and track.height_down move each track
one preset taller or shorter; a track already at Extreme or Micro is
left alone, with no undo step. Control+Up/Down runs them on macOS, as
in Pro Tools. Windows and Linux use Ctrl+Up/Down, because the Start
key Pro Tools uses there isn't available. Alt steps every shown track,
Alt+Shift the selected ones.
- Ctrl+Up/Down used to run Extend Edit Up/Down, keys Pro Tools doesn't
use for them. Those commands now have Pro Tools' Control+Shift+P and
Control+Shift+; on macOS (macOS sends the latter as Control+:).
Windows and Linux reach them from Edit > Selection.
- Off macOS, menus no longer show Mac Control chords that can't be
pressed there (Time Lock's Ctrl+Alt+L, Toggle Recent Playlist's
Ctrl+Alt+Shift+Left, the new Extend Edit keys); the arrow chords that
fixed() maps to Ctrl keep their labels.
Track headers show each row of controls only once it fits: the keys
from 46 points and the view and automation selectors from 86, where
each row ends. The old 30 and 60 point cutoffs suited the presets only
and cut controls off at heights in between. Every preset draws exactly
as before.
Tests: the model's custom heights, steps and JSON; the engine's point
heights and steps, with no undo step when nothing changes; in the UI,
the live drag and its single undo step, Cmd, Alt and Alt+Shift drags
that hold steady while the pointer does, the height keys on macOS,
Windows and Linux, and the Extend Edit keys on macOS.
No change in behaviour or look: the Edit and Mix windows render
pixel-identical in both themes.
- The Mix strip and the Edit window's I/O column share input_name
(In 1 for a recording track with no input) and route_popup, which
keeps the route menu open while its bus search is typed in.
- The Mix slots and the I/O column's output key share widgets::groove;
the Edit header meter and widgets::meter share level_pos.
- The column list is read once per frame and passed to each track row;
header_width takes it.
- The record, input, solo and mute keys are a table, the slot columns
one loop, and send marks a char instead of a String.
- Inserts look their plugin up once; the unused triangle_right icon is
gone.
In Pro Tools a send slot's name picks its destination, and only the
small fader icon at its right opens the send's level. Here the level
control covered the slot's right 42 points, more than half of an Edit
window cell, while the icon is 5 points wide. It now covers the icon and
3 points either side.
The icon does what the whole level area did: a click opens the Send
window, Alt-click sets 0 dB, right-click opens the send's options and a
drag sets the level. A short track's square has no icon, so all of it
stays the level control.
Measured from the user's Pro Tools screenshots:
- Track headers are 136 points wide (were 236). A 14-point colour strip
holds an options button (a folder's open/close) and, at the bottom, a
key that shows or hides volume automation. The name is bold beside a
playlist menu and highlighted when the track is selected (the blue
outline that marked a selected tinted header is gone). Then come
16-point record, input, solo and mute keys (record stays dark red when
off, and a mute implied by another track's solo shows dark orange);
the track view; freeze, voice (dyn, or off to make the track
inactive) and automation mode; timebase and the Elastic Audio
algorithm (an instrument track's instrument); and a slim meter down
the right edge. Rows drop away on shorter tracks.
- The vol/pan and inserts summaries and the AUX/INST labels are gone:
the I/O and insert columns show them.
- The I/O column has input and output selectors, a fader key that shows
the track in the Mix window, and a black vol/pan readout.
- Insert, send and instrument columns are 75 points wide on a neutral
well, with 15-point cells every 18 points. An empty insert shows a
dot, an empty send its letter. Tracks too short for three cells show
a row of squares, with arrows for sends.
- A used slot is a rounded, raised grey button: its dot or letter, a
groove, the name centred in 9.5-point bold, and for sends a small
level box (the level in dB is in the tooltip; the box still opens the
Send window or drags the level). A bypassed insert turns blue. The Mix
window's slots share this drawing; filled ones don't brighten on
hover, which would cost the name its contrast.
- New slot_well, slot_empty, slot_face, slot_bypassed and slot_mark
colours replace insert_on, insert_bypass, send_on and slot_border.
mix_instrument_contrast now reads the slot's painted gradient, and
finds the empty instrument slot by its cell, which shows a dot
rather than "no instrument".
- The header's record, input, solo and mute keys carry accessibility
labels.
Part of #138.
The edit modes only partly worked. Shuffle changed Cut, Clear and Paste
but not moves or trims. Spot froze drags and had no dialog. Grid snapped
relative to whichever clip was under the pointer, and Relative Grid
acted like Absolute. Behaviour now follows the Edit Modes chapter of the
Pro Tools Reference Guide.
Shuffle
- Grabber drags lift the clips out (later clips close the gap) and land
them at the nearest clip boundary, pushing later clips right; an empty
track takes them at the session start. The drag outline shows where
each track's clips will land.
- Trims move the later clips (a trimmed start stays put and the material
slides under it). Duplicate, Duplicate and Extend, Repeat and Clip List
drops make room, and Paste over a selection replaces it.
- Moves lift each clip by id and close the hole it leaves, so crossfade
partners and unselected clips keep their audio, and a crossfaded pair
moved together stays a crossfade. Ripples never push clips past the
session start, never split a crossfade (refused with a reason), and
drops never land inside a clip or crossfade.
- A time- or edit-locked clip and everything after it stay put when
Shuffle closes or opens gaps. Edits that would push into one are
refused ("not enough room before a locked clip"), and trims stop short
of it. Insert Time and Cut Time are timeline operations and still move
everything.
- With Automation Follows Edit, automation moves with every ripple, up to
a locked clip, and travels with moved clips; with it off, Shuffle edits
leave automation alone. Shuffle Clear used to move the clips but leave
their automation behind (#232).
- Shuffle Lock: Cmd-click (Ctrl-click on Windows and Linux) the Shuffle
button while in another mode. While it's on, the button shows a padlock
and every way into Shuffle (keys, button, commands) is refused.
- A drag works out every lift and landing point once per target track
(`lift_plan`, `shuffle_slots`, and a `DragPlan` kept for the drag). In a
release build, dragging 100 clips on a track of 20,000 took 29 ms a
frame and a 135 ms drop; now it's under a microsecond a frame and a 5 ms
drop.
Slip, Spot and Grid
- Slip: trims stop at the neighbouring clips instead of overlapping them.
- Spot: a Grabber click or drag, a Trim click or drag and a Clip List drop
open a Spot dialog: Start, Sync Point and End in any time scale, the
Duration, and the clip's Original Time Stamp. Trim clicks pick the
nearer edge.
- Grid: a drag snaps the dragged clip's start (its sync point, when it
has one inside the clip) to the grid. Relative Grid moves by whole grid
steps and keeps the clip's offset from the grid; a cut or copy pasted
in Relative Grid lands as far after a grid line as the copied range
started after one. Holding Cmd (Ctrl on Windows and Linux) while
dragging or dropping from the Clip List swaps Grid and Slip. Grid lines
no longer fall a step short at tempos where a line isn't a whole number
of samples.
- Choosing Grid from another mode picks whichever of Absolute and
Relative Grid was used last; F4 in Grid toggles them, like clicking the
Grid button again. Options > Edit/Tool Mode Keyboard Lock, which did
nothing, now stops F4 and Alt+4 from switching them (toolbar clicks
still do).
- Snap to Grid in Shuffle, Slip and Spot: Shift+F4, Shift-click on the
Grid button or on another mode's button, or hold F4 with F1-F3. The
Edit cursor and selections snap to the grid while clip moves and trims
follow the main mode; the Grid button is outlined while it's on.
- Option+1-4 (Alt+1-4 on Windows and Linux) work like F1-F4, and the `
key cycles Shuffle, Slip, Spot and Grid, skipping a locked Shuffle.
- Insert Silence pushes later material only in Shuffle; in the other
modes it clears the selection, as in Pro Tools.
Also
- Dragging clips from several tracks keeps each track's distance from the
grabbed clip's track. Drops past the last track, or onto a track that
can't play the clips (audio onto MIDI, an aux or the master), are
refused; clips moved onto an aux used to vanish into its unused
playlist.
- Undo and Redo keep the Edit mode, Shuffle Lock, Snap to Grid and the
Grid kind. Start trims keep a clip's sync point on its audio, or drop
it. Imported BWF time stamps are converted from the file's rate (read
from its header) to the session's, and the Original Time Stamp no
longer adds the session start twice. Typed positions that overflow are
ignored instead of panicking.
Commands: `edit.move_clips` follows the mode, and its `track` moves the
first clip's track there and keeps the others' distances. `edit.trim_clip`,
`edit.snap_to_grid` (Shift+F4) and `edit.shuffle_lock` are new, and
`edit.mode` takes `snap_to_grid`. Mode buttons expose their names to
accessibility tools.
Tests: crates/{engine,ui-egui}/tests/edit_modes.rs and the op tests in
edit.rs; the random-edits property test also toggles clip locks.
midi.new_clip had no UI path, so an empty instrument/MIDI track could
never get a MIDI clip (#282). The menu bar is built from the catalog,
so the entry is added to menus::tree() and placed first in Clip.
The new test checks every mutating command is reachable from the GUI
(built menu tree, shortcut, or UI reference), with today's gaps listed.
Closes#282
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Parallel test runs could flake: theme::LIGHT_THEME is process-wide, and
the edit_window harness tests ran whole frames without holding
lock_test_palette(), so a theme test could flip the palette between a
harness test painting rows and looking them up by colour (and a harness
frame could reset it to dark mid-theme-test).
Make the test palette lock re-entrant per thread and take it inside
SoundApp::new (test builds only), so every test that builds an app owns
the palette for the app's whole lifetime. Adds a regression test that
the lock is re-entrant on the owning thread and excludes others.
Fixes#323
Waveforms looked blocky and jagged next to Pro Tools': each column was a
point wide (two pixels on Retina screens), and the overview only knew
32-sample blocks, so zooming in showed stair steps and, at the sample
level, wide flat bars instead of the signal. Several View > Waveforms
options did nothing or drew something else.
- Peaks keeps one tree of blocks (min, max and sum of squares). Its
queries read the partial blocks at a range's edges from the samples,
so every answer is exact and still O(log n). `Peaks::spans` gives each
column the (min, max) of the line through the samples over its span;
neighbouring spans share their boundary, so the waveform stays
connected at any zoom.
- The Edit window draws a column per physical pixel, each over a
1-point window anchored to absolute sample positions (no shimmer while
scrolling), with anti-aliased ends, in one triangle strip where runs
of equal columns such as silence share vertices. At the sample level
(a sample per pixel or less) the waveform is a 1-point line through
the samples, without Rectified, Power or Outlines, as in Pro Tools.
- Outlines draws an edge around a lighter fill, as Pro Tools does; it
used to replace the fill with a line of dots along the tops.
- Power shows the RMS, which is how Pro Tools calculates it
(`Peaks::rms_spans`). Ours squashed the peak heights with a square
root, which drew loud material bigger than Peak view. Power draws
+/-RMS around the centre, or 0..RMS above the base line when
Rectified.
- Dark theme clips have a dark body tinted with the clip colour and a
light waveform in that colour, as in Pro Tools, instead of black
waveforms on a mid-tone body; a selected clip inverts to a bright body
with a dark waveform, as before. MIDI notes on unselected clips take
the waveform colour, with velocity as opacity. The Light theme is
unchanged.
- Blocks view draws clips as empty blocks with their names, and in
Playlists view the alternate playlists' lanes draw their clips like
the main lane, waveforms included, instead of empty boxes.
- Overlapped Crossfades draws the outgoing clip's waveform, translucent,
over the crossfade, so both clips' audio shows inside it. The outgoing
clip is found by binary search in the clip's own playlist. A crossfade
also shows both fade curves: the outgoing clip's fade-out used to be
hidden under the incoming clip.
Tests: the peaks unit tests check every range, span and RMS against the
samples; tests/waveform.rs checks the sample-level line, a one-sample
spike zoomed out, Blocks and playlist lanes, and Overlapped Crossfades.
In Pro Tools, clicking a send opens its Send window: the send's fader,
pan, Mute and Pre/Post. SoundCraft turned the send's level readout into
a text field instead, so there was no fader to grab.
- A click on a send's level in the Mix window opens the Send window:
the destination, Pre and Mute buttons, a pan knob, the fader (Alt-
click for 0 dB, Cmd/Ctrl for fine moves, down to −∞) and the level,
which takes a typed value. It stays open while you play.
- One Send window at a time, as with Pro Tools' Target lit: clicking
another send's level moves the window to that send, and clicking the
same send again closes it. The send shows an outline while its window
is open. Removing the send or its track closes the window.
- Escape closes it too, unless the Escape is for something on top: an
open menu, a level being typed, a dialog or the search. egui closes
menus and drops text focus on Escape before the window is drawn, so
that is read from the previous frame.
- Dragging the level and Alt-click on it still work in the strip, and
right-click still opens the options menu.
- The send's level and the window's fader have accessibility labels
("Send A level", "Send level").
Commands that already worked but had no key get the ones Pro Tools uses,
on macOS, Windows and Linux alike (Alt is Option on macOS; Cmd is Ctrl
elsewhere):
- Shift+= and Shift+- step the Grid value to the next larger or smaller
one, and Alt+Shift+= and Alt+Shift+- the Nudge value, as the Pro Tools
Reference Guide gives them. `view.grid` and `view.nudge` take `step`.
`GridValue::stepped` walks the note values, keeping a dot or triplet, and
steps seconds, frames and samples by tens. macOS sends Alt+Shift+= as =
(the key under ±), so that arm takes = as well as +. Both commands list
the keys as descriptive shortcuts, like Nudge's Plus/Minus, so they show
wherever commands list their shortcuts; the strings don't parse, so the
registry never dispatches them and `shortcuts::fixed` handles the keys.
- Commands Focus: , and . nudge back and forward, M and / nudge by the next
larger Nudge value (`edit.nudge` takes `next`), 0 copies the Timeline
selection to the Edit selection and O the other way, [ and ] play the
Edit and Timeline selections, and Shift+Z redoes. Shift+P and Shift+;
add the track above or below the selection, keeping its range (P and ;
already move it). They run Extend Edit Up/Down, which now skip tracks
the Edit window doesn't show (hidden, or in a closed folder); before,
they could add an invisible track. Shift+; can arrive as :, so both are
matched.
- Playlists: Alt+Shift+Up and Alt+Shift+Down copy the selection to the
target playlist and to a new one, Alt+Shift+T and Alt+Shift+N move it
there, Shift+Right shows the target playlist and Cmd+Shift+Right
designates the shown one. The MIDI editor's piano roll moved notes on any
arrow key, whatever the modifiers; it now leaves Alt, Cmd and Ctrl arrows,
and Shift+Left/Right, to these shortcuts.
- F12 records, as in Pro Tools. Cmd+Space is Spotlight on macOS.
- Ctrl+Tab goes back to the previous clip boundary off macOS, where Windows,
GNOME and KDE take Alt+Tab; Pro Tools for Windows uses Ctrl+Tab too.
Cmd+Alt+Tab now toggles Tab to Transient: the Tab arm took it as Alt+Tab
and tabbed back instead.
- Windows: Alt+7 Universe, Alt+6 Clip Effects, Alt+= MIDI Event List,
Shift+K MIDI Keyboard, Cmd+Alt+J Window Configuration List, Alt+' Task
Manager, Alt+I Workspace, Alt+J Workspaces to Front.
Tests drive the keys through `shortcuts::handle` for each OS
(tests/pro_tools_keys.rs), plus the piano roll with modifier arrows held.
The Edit window painted the bar and grid lines on the empty lane and then
the opaque clip bodies on top, so the grid vanished wherever an audio or
MIDI clip sat. Pro Tools draws grid lines over the clips.
- While grid lines are shown, the lines are drawn after the clips (audio,
MIDI, video and their overlays) and before automation and the selection.
- The lines are a translucent white (Dark) or black (Light), worked out
from the palette's line and lane colours, so a line on an empty lane looks
as before and waveforms and notes stay readable under it. Bar lines now
also stand out where a grid line lands on them; the opaque grid line used
to cover them.
- With grid lines hidden, the bar lines stay behind the clips as before.
Test: tests/grid_lines.rs renders the demo Edit window in both themes and
checks that the lines over the Kick clip are painted after it only while
grid lines are shown, and that each one looks like the theme's line colour
on an empty lane.
Unknown keys in `params` were silently dropped, so a typo like `gain_db`
reported `ok: true, set: 0` — indistinguishable from a successful no-op.
The command now fails and lists the unrecognized keys (and non-numeric
values), matching how `mix.insert` already validates its params. Preset
recall in the plugin menu drops stale keys before calling the command.
Fixes#266
The Tracks and Groups lists met the Edit and Mix windows at a faint
one-point line. A 10-point grey gutter with dark edges now runs down
the panel's right side and carries each list's scroll thumb when the
list is longer than its space: drag it, or click above or below it to
page. egui's own scroll bars are off there.
Finding a plugin for an insert took three submenus (format, vendor,
name). The send and I/O bus menus had a search field, but it had to be
clicked before typing, and it kept the last search.
- The send and I/O bus menus take typing as soon as they open, as Pro
Tools' path selectors do. The search starts empty each time; Up/Down
move through the matches, Enter picks one, Escape closes the menu.
- Insert and instrument menus start with the same search field. Typing
lists the plugins whose name or vendor matches: built-in ones first,
then Audio Units and whichever of CLAP and VST3 have been scanned,
with a Scan button for each of the others. Typing never starts a scan
(`cached()` in the CLAP and VST3 hosts returns the last scan without
scanning); opening a format's submenu still scans it.
- A click inside the insert and instrument menus no longer closes them,
so the field can be clicked; picking still does, and errors from
Bypass and "no insert" now reach the status line. The send bus menu
is no longer 600 points wide.
- Insert, send, instrument and I/O slots in the Mix window get
accessibility labels, which the tests use.
As in Pro Tools, a track's colour now runs through its whole row
instead of only the strip at its left edge: the header (and any
header columns) takes a muted shade of it and the lane a faint one,
so clips in the full colour still stand out.
- Window > Color Palette has a "Track tint in the Edit window" slider;
at zero tracks look as before. The setting is saved with the UI
prefs.
- A selected track's header gets an accent outline, since its blue
background would otherwise be lost under the tint.
The Light palette was near-white with a blue tint. Pro Tools' lighter
look is silver, so panels, strips, lanes, rulers and keys move to
neutral greys between 184 and 236, and meters sit in dark wells as they
do in Pro Tools. Borders and text are darkened to keep their contrast,
borders a little more so that tracks, Mix sections and controls don't
run into each other. Dark is unchanged.
Part of #138.
The shared controls were flat rectangles and discs. They now follow Pro
Tools' shapes and colours, drawn in code with vertex-coloured meshes (no
assets), and keep a modern, soft shading:
- Faders: a silver cap, taller than wide, with a dark centre line and
faint grip ridges, in a slot that leaves room for the cap at both
ends.
- Pan knobs: a softly shaded disc with a light rim and a white pointer
beside the green value arc.
- Keys (S/M/I, record arm, toolbar and transport buttons, edit modes,
Grid, Count Off, selector boxes, filled insert, send and instrument
slots): lit from above and sunk while pressed. Record arm shows an
unlit salmon light when off. The edit-mode keys take their width from
the widest label, so SHUFFLE and REL GRID fit.
- Meters: LED segments with thin gaps, the unlit ones faintly visible.
- Mix sections and the pan and volume readouts sit in outlined wells.
Strips get a soft gradient and a lit left edge, track headers a lit
top edge, and a line separates the Edit window's headers from the
lanes.
- Filled insert slots are grey, as in Pro Tools. They no longer
brighten on hover: on the grey, that dropped their label below 4.5:1
in Dark. mix_instrument_contrast now finds the key's face and rim,
and checks that hovering keeps the face.
Part of #138.
Pro Tools lets you work with a track's plugins from the Edit window.
Here its Inserts and Sends columns (View > Edit Window Views) only
printed the names.
- The Inserts A-E/F-J and Sends A-E/F-J columns now use the Mix
window's slots, so they work as they do there: click a plugin to open
its window, click an empty slot to insert one, right-click to bypass,
remove or replace it, and route or set the level of a send.
- A new Instruments column shows an instrument track's instrument.
- The columns run in Pro Tools' order: Instruments, Inserts, Sends, I/O,
Comments. Each gets a title, boxed in its own row under the rulers,
and the ruler names now end at the track headers.
- A Views button at the top left of the rulers shows and hides the
columns, like Pro Tools' Edit Window View selector. Its menu stays
open until a click elsewhere, so several columns take one trip.
- In both windows, insert slots show a power light, green while the
plugin runs and dark while it's bypassed, with the name left-aligned
and dimmed when bypassed. A plugin whose code is missing shows its id
instead of looking like an empty slot.
- Cmd+click (Ctrl+click on Windows and Linux) bypasses an insert, as in
Pro Tools.
- Each column draws in a Ui of its own, so its widget ids can't clash
with the track header's.
Stereo strips draw two smaller pan knobs (32pt or less instead of 34pt),
and the rows under them were placed from the knob size. So the pan
readouts, the I/record and S/M keys and everything below them sat 2pt
higher than on mono strips. The rows now use the mono positions on every
strip.
The fader's dB numbers and ticks were painted in fixed greys (150 and
110), which are faint on the Light theme's strips: the numbers had a
2.5:1 contrast. They now use the theme's text_dim, at 70% for the ticks.
Light reads at about 5:1 and Dark looks the same as before.
Each tick now starts 2pt after its number instead of running through the
digits, and is left out when the strip is too narrow for it.
Opening a session decoded every source entry on its own, so a 400 KB
session listing one 11 MB WAV 2,000 times took 16 s and 22 GB, and
20,000 entries never finished. Import Session Data and clip group files
did the same.
All three now decode a file the first time it is named, by its
canonical path, and share the decoded audio with later sources that name
it; the pool's audio is never changed in place, so sharing it is safe.
Saving keeps sources that play one file on that file, and an autosave or
a copy writes shared audio once instead of once per source. That session
now opens in 0.3 s at 110 MB, and a normal session bounces to the same
bytes as before.
Commands clamp every position they take to ±2^40 samples so the
arithmetic after them can't overflow, but session and clip group files
skipped that. A clip, MIDI note, marker, automation point or selection
near i64::MAX made Duplicate, Insert Silence, Insert Time, Heal, Trim to
File Boundaries, Quantize to Grid, Separate on Grid, Thin Automation,
Copy, Move Selection, Extract Chords and Track Commit overflow: a panic
in debug builds, wrapped positions in release.
MAX_POSITION moves to soundcraft-time so the model can use it. Loading
a session clamps clip starts, lengths, offsets, sync points, gain
envelopes and MIDI notes, automation points, markers, key changes, the
selection, playhead, pre/post-roll, scroll and timecode start. A clip's
repairs are now Clip::sanitize, which clip group import uses too, and
Session::sanitize visits each collection once. The Edit window adds a
clip's offset to its file's time stamp without overflowing.
A MIDI file of 317 bytes, with one note whose end lies 41 maximal
deltas after its start, imported as an 8.5-year clip. Everything that
then rendered or walked that clip (Track Bounce, Commit, Separate at
Transients, Extract Chords) worked for seconds to forever and used up
to 3 GB.
Importing now refuses a MIDI track that ends more than 24 hours after
the import point; its end is computed with saturation, so a huge one
can't wrap around and slip past the check. A file with more than 10,000
meter changes is refused too: placing each one walks those before it,
so 200,000 of them (1.8 MB) took 57 s and fit easily within a day.
Extract Chords walked the selection bar by bar, looking at every note
of the selected tracks in each bar, until it had 2000 chords or reached
the end. After Select All over a session file with a clip at the far
end of the timeline (2^50 samples) it ran for ever over empty bars, and
a 317-byte MIDI file with one note held for 8.5 years kept it busy for
millions of bars that never form a chord.
It now takes the notes in time order, keeps only those sounding in the
bar it looks at, jumps over bars where nothing sounds, and stops after
100,000 bars that hold notes (27 hours at 120 BPM) or 10 million
sounding notes. The chords it finds are the same.
A MIDI file whose track name was a million characters long imported
fine, and the next frame crashed the app: drawing that name needed a
320 MB vertex buffer, past wgpu's 256 MB limit, and the panic comes
from painting, outside anything that catches it. Saved, the session
crashed again on every open. A 4 MB status line in ui.json did the
same at every launch.
Names read from files are now cut to 256 characters and comments to
4096: everything Session::sanitize repairs on load (session, track,
playlist, clip, marker, bus, output, group, source, video and key
names), MIDI track names on import and clip names from clip group
files. The status bar draws at most 1024 characters.
Finding a free name tried "Name 1", "Name 2" and so on, rescanning
every track (or every source path) for each try, so each new name cost
time proportional to the square of the session and a bulk import the
cube. Importing a 119 KB MIDI file with 4,095 tracks all named "Piano"
took 28 seconds; a clip group file with 8,000 empty lanes or 8,000
audio files of one name ran for over a minute, and 100,000 lanes never
finished. Import Session Data from a session with 2,000 same-named
tracks took 7 seconds.
unique_track_name and the new audio file name now collect the numbers
already in use once and take the first free one, which gives the same
names. A clip group file also counts each empty lane and each audio
file against its limit of 10,000. The same imports now take 1 second
(MIDI), 2 seconds (8,000 lanes) and a third of a second (Import Session
Data); 100,000 lanes are refused.
An 80 KB WAV with a sample rate of 1 Hz froze the app on import: the
size limit was checked before conversion to the session rate, which
then made it 48,000 times longer, past 2 GiB of memory and more than a
minute of work (4.5 GiB and 75 s in an earlier measurement). Opening a
session, Import Session Data, the sampler and clip group files
converted without any limit.
io::at_rate checks the size the audio will have at the session's rate
before converting it. Imports keep their limit (2^30 samples, about
4 GiB) and their message; the sampler keeps its own limit; a session's
source that is too large is reported like other unreadable media.
A session whose audio file path pointed at /dev/zero filled 18 GB of
memory while opening, and one pointing at a named pipe waited forever.
The same went for a path given to the import commands, the sampler,
Import Session Data, clip group and track preset files, and the session
file itself, all of which read whatever the path named to its end.
Those reads now go through io::read_file / read_text, which refuse
anything but a regular file. A session's missing audio is reported as
missing, as before; an import fails with "not a regular file".
A session file with a sample rate of 0 or a tempo of 4e9 BPM opened
fine, but Strip Silence on the first and Extract Chords on the second
never finished (one also climbed past 1 GiB). Session::sanitize, which
repairs everything else read from a file, skipped both fields.
On load, a sample rate outside what sessions accept becomes 48 kHz, and
the tempo map keeps only the tempos and meters that set_tempo and
set_meter accept, in tick order, with one of each at tick 0.
WAV files that aren't plain PCM (A-law, µ-law, ADPCM) go to symphonia,
which reads every entry of an INFO list into a buffer of its declared
size, bounded only by the list's and file's declared lengths. A file
claiming a 4 GB RIFF and a 2 GB title asked for 2 GB before failing.
macOS gives that lazily; a 32-bit build or a system that can't commit
it aborts.
Before symphonia reads such a WAV, its INFO entries are checked against
the bytes the file holds, and one that claims more is refused as
malformed. Chunks are walked where symphonia reads them, not where their
lengths say: A-law, µ-law and ADPCM `fmt` chunks are read by their own
extra size, extensible ones by 40 bytes, and a list is padded twice.
An MP3 whose ID3v2 tag had a frame claiming 0x7FFFFFFF bytes made
symphonia's probe allocate 2 GB to read that frame (4 GB at
0xFFFFFFFF). It reads every tag it finds ahead of the audio, though
SoundCraft never uses the result. macOS gives such memory lazily; a
32-bit build or a system that can't commit it aborts.
The probe loop now skips an ID3v2 tag by the size in its header and
carries on looking for the audio, so a tag can't cost more than the
bytes it occupies, and a damaged tag no longer stops an MP3 from
opening.
symphonia reads a FLAC file's picture and Vorbis comment blocks field
by field, allocating each field's declared length before it finds out
the block is shorter: a picture claiming 0xFFFFFFFF bytes of image
data asked for 4 GB. macOS gives that lazily, but a 32-bit build or a
system without that much memory to commit aborts.
When the probe finds a FLAC stream, the lengths of its picture fields
and comments are now checked against their metadata block first, and a
file whose fields don't fit is refused as malformed.
A 59 KB ALAC file in an M4A made the app use 25 GB of memory before it
gave up: symphonia's ALAC decoder sizes its buffers from the frame
length in the file's codec settings, and nothing bounds it. The PCM and
ADPCM decoders likewise allocate the frames per packet the container
declares, which an MP4 version-2 LPCM entry takes from the file.
Before a decoder is made, the frames a packet may hold, and an ALAC
frame length, must be at most 2^20 (about 22 seconds at 48 kHz;
Apple's ALAC encoder uses 4096), or the file is refused as malformed.
An M4A whose handler atom claimed a 64-bit size of 2^60 bytes ended the
process on import: symphonia reads `hdlr` and the `data`, `name` and
`mean` atoms of iTunes metadata into buffers of their declared size, and
an allocation that fails aborts. Its sample tables reserve room for
whatever entry count they claim (up to 68 GB from a 19 KB file), and
every sample is read into a buffer of the size `stsz` gives it. macOS
hands out most of that lazily; on Linux, Windows and the 32-bit Windows
build such a request fails and aborts.
When the probe finds an MP4 file, the atoms symphonia descends into are
now walked first, the way symphonia reads them: a header that starts
inside its container is read whole, and the walk ends where symphonia
stops, after an atom that runs past its container. A file is refused as
malformed when one of those atoms, a sample table or a sample claims
more bytes than the file holds. Atoms symphonia skips, and an `mdat` cut
short, are left alone, so truncated recordings still read.
Importing a 72-byte CAF file ended the process. symphonia allocates
whatever a CAF magic cookie, packet table or packet claims before it
reads it, and when that allocation fails Rust aborts, which no
catch_unwind can stop. A cookie claiming 54 PB, a table of 2^50
packets and a single packet of 2^50 bytes each did it, on import and
in `soundcraft-cli info`.
symph::open now runs symphonia's probe loop itself, and when the
container it finds is a CAF stream, it first walks the chunks the way
symphonia reads them. A file whose cookie, packet table or packets
claim more bytes than it holds is refused as malformed. So is a packet
table symphonia would refuse only after reserving 32 bytes per packet:
one that runs past the file, or one listing packets of a constant bit
rate stream. The probe's hint was never read by symphonia 0.5, so it is
no longer passed.
Fixes#316: in a new session, clicking Record did nothing visible. With
no record-enabled track there is nothing to record, and the reason went
only to the end of the dim status line at the bottom of the Edit
window. In a new session it also pointed at "the red button in its
header", which no track existed to show.
- Record with nothing to record on says what to do: add a track with
Track › New… when the session has none, else click R on the track
to record.
- The message shows for 4 seconds below the toolbar's Record button
(under the whole transport group, so it covers none of its buttons)
and under the Transport window's Record button, as well as in the
status line. It goes early once a track is record-enabled.
- Nothing is armed or created on the user's behalf, and the transport
stays stopped.
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.
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).
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.
Timecode::to_frames multiplied caller-supplied hours in i64 without a
check, so "9223372036854775807:00:00:00" panicked under overflow checks
and wrapped without them. The count is now computed in i128 and clamped
to i64, and samples_at_frame clamps instead of returning 0 on overflow,
so huge labels saturate toward the right end of the timeline.
bar_start_tick subtracted 1 from the bar before its saturating multiply,
so the minimum i64 bar panicked under overflow checks. Parsing
"-9223372036854775808|01|000" as Bars|Beats now returns a saturated
negative position instead.
Imported MIDI keeps its controller events: sustain pedal and other CCs,
pitch bend, channel and poly pressure, program changes. Playback sent
instruments only note-ons and note-offs, so a piano part lost its pedal
and bends went flat.
- Plugin::midi takes any other MIDI 1.0 channel message at a sample
offset. The mix engine sends each clip's controllers at their sample,
after the Real-Time Properties delay (and transpose, for poly
pressure), on channel 1 like the notes.
- After a seek, a stop or a loop wrap, an instrument first gets Reset All
Controllers (CC 121), then the last value each controller had on its
track before the playhead. A pedal held before the start point is
still down.
- CLAP: raw MIDI events when the note port speaks the MIDI dialect.
- VST3: CCs, aftertouch and pitch bend become parameter changes at their
offset, through the plugin's IMidiMapping. It is asked once in prepare,
on the main thread, and there is one point per parameter per block.
Poly pressure is a PolyPressureEvent. Program changes have no VST3
mapping and are dropped.
- AU: MusicDeviceMIDIEvent for instruments and music effects.
- The built-in synth follows the sustain pedal, pitch bend (2 semitones
each way), Reset All Controllers and All Notes Off.
- The sampler ignores the pedal, pitch bend and Reset All Controllers
for now.
The AU check of MIDI volume lives inside the existing DLS test. Creating
two DLSMusicDevice instances at once on two threads crashes inside the
unit, with or without this change. Two instances created one after
another render in parallel fine.
Hosted plugins never learned the session's tempo or where the song was:
- CLAP got a null transport;
- VST3 got a fixed 120 BPM 4/4 context that never played and counted
processed samples as the song position;
- Audio Units had no host callbacks.
Tempo-synced delays, LFOs and arpeggiators ran at 120 BPM and ignored
the transport.
Before each block the mix engine now hands every insert, instrument and
master-fader plugin a soundcraft_dsp::Transport (Plugin::set_transport):
play and record state, tempo and meter at the block start, and the
timeline position in samples, seconds and quarter notes, with the
current bar. Built-in plugins ignore it.
- CLAP: a clap_event_transport (fixed-point beats and seconds, bar start
and number, time signature, play and record flags).
- VST3: ProcessContext tempo, time signature, project time in samples and
quarter notes, bar position, kPlaying and kRecording. Continuous time
stays the processed-sample count.
- AU: kAudioUnitProperty_HostCallbacks (beat and tempo, musical time
location, transport state 1 and 2), read from atomics the instance
owns and frees after disposing the unit.
Loop (cycle) ranges aren't reported yet: playback keeps the loop range
to itself.
The Edit window copied the whole session once per frame for the rulers and
twice for every visible track (track_row and lane_interaction), plus once more
for each track shown as playlists and once for the Timecode 2 ruler. The MIDI
editor copied it once per frame, and every Edit row and Mix strip copied its
track. A copy includes every plugin's parameters and saved state: in a 23-track
session with three plugins per track (200 parameters and 64 KB of state each),
one copy took 0.2 ms and each Edit window frame 15.6 ms, which is paid on every
frame of playback, dragging and scrolling.
These now hold the engine's Arc<Session> (session_arc). It is still a snapshot:
a command run later in the frame copies on write, as it already does to keep
its undo step. timebase_ruler takes the frame rate as a parameter, so the
Timecode 2 ruler no longer needs a copy with another frame rate.
Median frame times in a release build, with 21 of 23 tracks on screen:
- The demo session with its tracks tripled: Edit window from 1.25 to 0.68 ms.
- Plus 5,000 volume automation points per audio track: from 2.03 to 0.68 ms.
- Plus three plugins per track: Edit window from 15.6 to 0.69 ms, Mix window
from 1.14 to 0.71 ms.
The Edit window (every ruler, a playlist lane and an automation lane), the MIDI
editor and the Mix window render byte-identical images before and after.
Consolidate clamped its range to the track's last clip end plus one
second, so on an empty track a 0-5 s selection made a 1 s clip and a
2-7 s selection made nothing. It now renders, clears and places exactly
the selection, and refuses a selection too long to render instead of
placing a shorter clip. Prepare DPE keeps the old per-track bound.
Fixes#164
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#181 already pushes instrument params each block, so drop the duplicate
apply_instrument_params. Import Session Data now brings a Sampler's sample
along and remaps its source id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes#166: dragging a selection up from the last track deselected it and
selected every track above instead. Fixed, along with related Edit-window drag
problems:
- Drag selection spans exactly the tracks between the press and the pointer,
in either direction, and the whole window updates in the same frame.
- Shift+click in another track adds it to the selection with the same range,
as in Pro Tools, so the selected tracks no longer have to be adjacent. A
click outside the range extends it.
- Track names follow Pro Tools' rules in the Edit window, the Mix window and
the Tracks list: Shift+click selects every track between the nearest
selected one and the clicked one, and Cmd+click (Ctrl+click on Windows and
Linux) adds or removes one. A click on a name used to replace the selection;
only the Edit header's background added, and it treated Shift like Cmd.
- Dropping a track's name plate lower down no longer sends the track to the end.
- Drags act where you pressed: a quick drag from a clip edge now trims instead
of moving the clip.
- Dragging past the edge of the track area scrolls, and a flick past the last
track selects down to it.
- The insertion point moves on press, shows immediately, and only on the
selected tracks.
- edit.select no longer makes a new document revision when nothing changed
(each one is resent to the audio engine).
- Fixes a panic when a fade drag's clip shrinks to zero length.
In Pro Tools, E (with Commands Keyboard Focus on) zooms the Edit window
to the edit selection and fits the selected tracks to the window, and
pressing it again brings back the view from before. SoundCraft had no
Zoom Toggle.
view.zoom_toggle, in its own command module, stores the zoom, the
scroll and the selected tracks' heights in the session's new
edit.zoom_toggle, so the view to go back to is saved with the session.
It then zooms so the selection fills the timeline and gives the
selected tracks equal shares of the track area: Pro Tools' Fit To
Window height. Hidden tracks are left alone. Run again, it puts all of
that back. With only an insertion point the zoom stays and its tracks
are fitted. The UI passes the track area's size; agents can pass
width_px and height_px.
TrackHeight::Fit, which nothing could reach and which drew 88 points
tall, now carries its height in points, so every place that sizes a
track row reads it unchanged.
The Edit window scrolls the first fitted track to the top when Zoom
Toggle turns on, and back to where it was when it turns off, whichever
way it was toggled (key, button, control channel, undo).
As in Pro Tools, E works in the Edit window while Commands Keyboard
Focus (the a-z button) is on, on every OS. A Zoom Toggle button left of
the Zoomer lights while it is on and toggles it too.
This follows Pro Tools' Zoom Toggle settings for audio editing:
Selection for both zooms, Fit To Window, no track view change. Its
other preferences, Ctrl+E (Pro Tools' key with the focus off),
Alt+Shift+E (cancel) and following the edit selection are left out.
Tests cover the zoom, the fitted heights, going back, insertion points,
hostile sizes and stored views, and E and the toolbar button on macOS,
Windows and Linux.
Keep the never-overwrite writer, and also give sources that share a path
their own files (#123). Save Copy In checks replace, then uses
save_session_copy (#124).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Request lines were read with BufRead::lines, which buffers a whole line
before anything looks at its length, and every accepted connection got its
own thread with no limit.
- Read each line with a bounded read: a line longer than MAX_LINE
(256 MiB) is refused with an error reply and the connection
closed, without buffering the rest of it.
- Serve at most MAX_CONNECTIONS (16) connections at once; further ones get
an error line and are closed. A slot is released when its connection
ends, including when its thread can't start or panics.
Request handling is otherwise unchanged. Adds tests for the line limit,
CRLF and final-line handling, an end-to-end request over loopback, and the
connection cap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes#166: dragging a selection up from the last track deselected it and
selected every track above instead. Fixed, along with related Edit-window drag
problems:
- Drag selection spans exactly the tracks between the press and the pointer,
in either direction, and the whole window updates in the same frame.
- Shift+click in another track adds it to the selection with the same range,
as in Pro Tools, so the selected tracks no longer have to be adjacent. A
click outside the range extends it.
- Dropping a track's name plate lower down no longer sends the track to the end.
- Drags act where you pressed: a quick drag from a clip edge now trims instead
of moving the clip.
- Dragging past the edge of the track area scrolls, and a flick past the last
track selects down to it.
- The insertion point moves on press, shows immediately, and only on the
selected tracks.
- edit.select no longer makes a new document revision when nothing changed
(each one is resent to the audio engine).
- Fixes a panic when a fade drag's clip shrinks to zero length.
- When every lane point lay inside the selection the lane was cleared,
so the held level outside fell back to the fader (6 dB louder in the
repro). Clear only when all clips lie inside the selection.
- The clip's own gain points inside the selection are kept, offset by
the lane, instead of being dropped.
- Volume/clip-gain ramps now end on the block's last sample, so
interpolate over n-1 steps and actually reach the end value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wgpu's default DX12 compiler (Auto) loads whichever dxcompiler.dll the DLL
search path finds. SoundCraft ships none, and without a matching dxil.dll the
unsigned-DXIL warning fails wgpu's indirect-validation pipeline, the device
is lost and the app exits before the first frame. Pin the compiler to FXC,
which is part of Windows; WGPU_DX12_COMPILER still overrides it.
Fixes#185
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hdiutil makehybrid -hfs put a com.apple.FinderInfo on every file in the
image, so the signed app inside the DMG (and any ditto copy of it) failed
codesign --verify --deep --strict. Build the image with
hdiutil create -srcfolder (retried, since create can hit "Resource busy" on
CI), clear build-time xattrs before signing, and verify the app inside the
finished DMG and a ditto copy of it with --strict.
Ported from storytold/pdfcraft#823 and storytold/lightcraft#959.
Fixes#176
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Close Session, New, Open, Revert, Quit, the window's close button,
dropping a session file on the window and opening one from the Workspace
window all replaced or closed the session without asking, so unsaved
edits were lost (#136). They now ask first: Save, Don't Save or Cancel.
Save on a session that has never been saved goes through Save As, then
carries on. Revert asks for confirmation instead, and Revert to Saved is
unavailable until the session has a file. A take being recorded is
stopped first, so it counts as a change. The web build has nowhere to
save, so it offers only Don't Save and Cancel. Calls from the control
channel, MCP and the CLI never ask.
Selecting, locating, zooming and scrolling no longer mark the session
modified, so the question only comes after real edits. Undoing back to
the saved state makes the session clean again. The engine compares the
document with the one last opened or saved, ignoring the selection, the
playhead and the zoom, once per revision.
Fixes#136
JSON has no NaN or infinity, so serde writes them as null, and loading
null into a plugin parameter failed the whole session with "not a
SoundCraft session: invalid type: null, expected f32". One plugin
reporting a non-finite value, or a value like 1e39 that overflows f32
in an edited file (it loads as infinity), made the session and every
later save and autosave of it unopenable.
Insert and instrument parameters that are null or overflow f32 are now
dropped on load, so they keep the plugin's default and the rest of the
session opens.
Nothing a save wrote was synced to disk, so a power cut or crash of the
system soon after Cmd+S could leave an empty or truncated session file
(the rename can reach the disk before the data), or audio files that
the session points at but that never reached the disk. No earlier
version of the session file was kept either.
New audio files, the Audio Files folder and the session's temporary
file are now synced before the session file is renamed into place, and
the session folder after the rename; templates are written the same
way. A failed write or rename removes its temporary file instead of
leaving it on a full disk. Before a save replaces the session file with
different contents, the previous version is copied to
Backups/<name> backup <n>.scraft beside it, and the newest ten are
kept. Autosaves make no backups.
Save As, Save Copy In, Save As Template and Export Selected Tracks as
New Session wrote over any existing file without asking. The Save As
prompt proposes ~/<name>/<name>.scraft, so every new session suggests
~/Untitled/Untitled.scraft, and saving a second new session with the
suggested path replaced the first one.
These commands now refuse to write over an existing file unless
`replace` is true; Save, Save As and Save Copy In may still write the
open session's own file. Scripts, MCP agents and the control channel
can't replace a session by accident. While the path in the prompt names
an existing file, the prompt asks whether to replace it and its OK
button becomes Replace; Enter alone doesn't replace. Saving also
creates the session's folder, so a new session without audio can be
saved to the suggested path. The CLI's --save names its output
explicitly and keeps replacing it, like --bounce.
Audio that exists only in memory (takes, consolidations, AudioSuite
renders, commits) gets the default path `Audio Files/<name>.wav`, and
Save treated an existing file at that path as the source's own and
rewrote it. Names repeat easily: two sessions in one folder both call
their first take on Audio 1 "Audio 1_02", and consolidating twice on
Kick makes two "Kick_consolidated" sources. Saving silently replaced
the earlier audio, so the other session or the earlier clip played the
wrong audio once reopened.
Save As also trusted a same-named file in the target folder for audio
saved elsewhere, so saving into another session's folder could point
the new session at that session's audio.
New audio now goes to the first free name (`<name>.wav`,
`<name>_01.wav`, ...), created with create_new so no existing file is
ever truncated, or to a file that already holds exactly the same bytes,
such as a take saved again after undoing past a save. Saved audio is
reused only when saving into the folder it was saved in, which the
session now remembers, so undoing past a Save As or saving after Save
Copy In can't pick up another session's files either. Audio that
failed to load keeps pointing at its old folder when the session is
saved elsewhere.
The app sent the window title to egui on every frame. Each viewport
command asks for another frame, so the window redrew at the display's
refresh rate with nothing happening: 35-43% of a CPU core and 13-20% of
the GPU per window on a 120 Hz Mac. While the window was covered or
minimized, eframe runs only App::logic, with no vsync to pace it, and
the same request kept one core at 100%.
The title is now sent only when it changes. The last title sent is kept
in egui's context data rather than compared with the window's, because
winit can't read a title back on X11 or Wayland.
Some work only ran because another frame always came, so an idle window
still wakes once a second, and every 30 ms while a plugin editor is
open. Autosave and the macOS menu bar's throttled refresh run on
frames, and plugin editors are native windows egui never hears from:
their edits (VST3 performEdit, CLAP output events) and main-thread
requests are serviced on frames. egui takes its predicted frame time
off every delay, so it is added back. The menu bar can now trail a
shortcut by up to a second instead of 250 ms.
Playback, falling meters, pending screenshots and scripted input ask
for the next frame from ui() instead of logic(). eframe skips ui() for
a covered or minimized window, so a hidden window runs on the heartbeat
instead of spinning, and egui redraws it as soon as it shows again.
Autosave was timed by egui's frame clock, which stands still while the
window is hidden, so a modified session wasn't autosaved until the
window showed again. It is timed by the wall clock now. Only the
desktop app sets an autosave folder, so the web build never reads it.
Tests check that an unchanged title sends no command, that only a shown
window animates while a hidden one wakes once a second, and that a
hidden window still autosaves.
Measured on a 120 Hz MacBook with a release build, the demo session and
audio off: an idle Edit window went from 35-43% CPU and 13-20% GPU to
1.5% and 0.4% (the insertion point blinks), the Mix window to 0.2%, and
a covered window, idle or playing, from ~100% CPU to 0%.
* ci: refactor workflows to use reusable artcraft-common-actions
* ci: point reusable workflows to storytold/craft-common-actions
* ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly
* ci: use @main (unpinned) for craft-common-actions with explanation comment
The window opened at 1600x1000 on every launch, and the interface scale
set in UI Customization reset to 100%. The app now keeps the window's
size, whether it was maximized and the scale in its preferences
(ui.json, saved on quit) and restores them at launch. The size is kept
in unscaled points, so a scaled interface doesn't change the window's
size from one launch to the next. The position is left to the system,
so a disconnected monitor can't leave the window off screen. A window
too big for the screen it opens on (sized on a larger monitor) opens
maximized instead and keeps its saved size for that monitor. Runs with
SOUNDCRAFT_NO_PREFS still start at the defaults.
- Pinch and Cmd+scroll zoom the timeline around the pointer. egui 0.36 turns
both into zoom_delta, so the old Cmd+scroll branch never ran; Shift+wheel
pans sideways again for the same reason.
- Two-finger panning keeps the sub-sample remainder, so slow motion still
moves at the closest zoom; the scrollbar drag pans the same way (its range
now takes in the view, so the thumb stays in the bar), and the rulers pan
too. Panning stays open-ended to the right, as before.
- A swipe keeps to the axis it starts along, so panning doesn't drift between
tracks and scrolling doesn't drift in time. The larger share of its first
6 points decides until egui reports the scroll over: the fingers lift, or
150 ms pass without wheel events.
- The MIDI editor scrolls by the point instead of rounding each frame's delta
to whole rows (slow swipes did nothing), and its range fits the panel
(pitch 0 could not be reached; a taller panel showed blank rows).
- Zoom and scroll write through `Engine::zoom_mut` (`view` commands): no
playback resync (a session copy sent to the audio thread every frame), no
copy of the whole session per frame, no journal entry per frame, and no
end to an undo gesture (scrolling during an automation pass made each
write its own undo step). New `view.zoom_by {factor, x?}`;
`view.scroll` saturates; Fill Window and Zoom to Selection stay within the
zoom range (zooming out after them on a long session zoomed in).
- Control channel: `ui.scroll` (trackpad swipe or wheel notches) and
`ui.zoom` (pinch); `session.inspect` reports zoom.
Track > Clear All Clip Indicators (Alt+C) was a stub: the red clip
indicators on the meters stayed lit until another session was opened.
The command now clears them on every track's meter and the main meter.
Record-enabled and input-monitored tracks were heard while stopped, but
their meters froze (#42). The audio thread returned before copying the
meters whenever it wasn't playing. The UI faded every meter to zero while
stopped and stopped redrawing once they reached zero. After a selection
had played to its end, the stale end point also silenced input
monitoring until the next Play.
The audio thread now copies the meters for every block it renders and
forgets the end point and loop when playback stops. While a track is
armed or monitoring input, the UI keeps feeding and redrawing its
meters, and the Mix window's per-channel meters include armed tracks.
A plugin window listed every parameter in one grid. VST3 and CLAP
plugins expose all of theirs, so a plugin with more parameters than fit
on the screen ran off the bottom edge, and the ones below it couldn't be
reached (#6). The parameters now scroll inside the window, below the
header and the EQ curve, and the window stays on the screen.
When a session is reopened with a saved instrument-level change such as
'level = -12 dB', the value sat in t.instrument.params but was never
written to the live plugin instance, so the bounce produced the same
audio as the default (-6 dB). The inserts loop already calls
apply_params per block; the instrument branch did not.
Call apply_params(slot, ins, t, INSTRUMENT_SLOT, pos) before each
instrument's process() so a saved level (or any other param set via
'mix.insert_state') reaches the plugin exactly the way insert params do.
Refs #152.
They ran on every push to main and on any PR touching Cargo.toml/Cargo.lock,
which is nearly every PR. See craftrules release/playbook.md §2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use the current populated insert fill and slot outline for Instrument
slots. This restores readable Light and System-Light labels while
preserving the original Dark colors, hover behavior and picker route.
Add a root-UI regression that checks actual emitted colors in four
palettes, preserves session/audio data, replaces the built-in instrument,
checks parameter defaults and restores the session through undo.
Include the authorized SMF match-guard prerequisite and a contract test
for first-name-wins and ignored metadata. All 16 focused SMF tests and
the complete populated Instrument regression passed.
Full CI passed formatting and cleared the SMF lint, then stopped on
unchanged metronome and MP3 Clippy expressions. Later CI stages did not
run. Full local CI before publication was explicitly waived.
Closes#161
- release.yml: new linux-riscv64 job, cross-compiled on ubuntu-24.04 against ports.ubuntu.com
riscv64 libs (the linux job's -dev packages incl. libasound2-dev:riscv64), CLI smoke-tested
under qemu-riscv64; wired into the release job's needs so SHA256SUMS covers it.
- packaging/linux/package.sh: CROSS_ARCH/CROSS_TARGET/CROSS_COMPILE/EMULATOR support.
- clap-host: c_char -> u8 without a cast that is a no-op (clippy error) where c_char is u8.
- Version 0.4.0 -> 0.5.0; docs list the riscv64 tarball.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A second consolidate of the same track reused Audio Files/{name}.wav, and saving then skipped the rename because that path already belonged to the earlier source. New sources now take a free name, and a save splits sources that already share a path instead of overwriting one file.
Save Session Copy In wrote through the normal save, which renamed the session, marked it clean, and pointed it at the copy. The open session now stays dirty, named, and path-ed as it was, while the copy is a separate folder.
file.export_midi copied notes only, so controller, pressure, and pitch-bend events were dropped, and the tracks parameter was ignored. The file now includes those events, and a tracks list limits the export. Naming only non-MIDI tracks is an error instead of exporting every other MIDI track.
A new track for an imported file was clamped to stereo, so a 5.1 file lost every channel past the left and right. The track now follows the file's channel count. Importing onto an existing track is unchanged.
The Window menu's keys never fired: Cmd+1 Transport, Cmd+3 Big Counter,
Cmd+4 Automation, Cmd+5 Memory Locations, Cmd+9 Video, Cmd+Alt+M Narrow
Mix, Cmd+W Close Window, Cmd+Ctrl+W Hide All Floating Windows and
Cmd+Ctrl+= Score Editor. The shortcut handler only looked at engine
commands, and these are window commands. It now falls back to them, and
the menus and the Keyboard Shortcuts window show their keys.
On a Mac, Cmd+Ctrl+= toggled Mix/Edit instead of opening the Score
Editor. The Mix/Edit toggle now needs Cmd+= without Control there.
The two Control chords still have no key on Windows and Linux.
The Edit menu always read "Can't Undo" and "Can't Redo", the incumbent's
labels for an empty history, even when there was something to undo. The
items now name the step they would take ("Undo Mute", "Redo Mute"), in
the in-window menu bar and the macOS system menu alike, and read "Can't
Undo" / "Can't Redo" only when there is nothing.
Clip > Rename... and Cmd+Alt+Shift+R ran clip.rename without a name, so
they only put "`name` required" in the status line. They now open the
Rename Clip dialog, which only the clip's right-click menu opened so
far, for the selected clip (or the first clip the edit selection
touches).
The dialog's name field, and Rename Track's, now opens with the old
name selected, so typing replaces it instead of adding to it.
The web build could not reach any file: File > Open Session…, Import > Audio… and Import > MIDI…
showed a text box for a path the browser can't read, and every save or export failed with
«operation not supported on this platform», so work was lost on close.
On the web now:
- Open Session…, Import Audio… and Import MIDI… show the browser's file picker; files dropped on
the page open (sessions) or import (audio, MIDI) by extension. Files travel as bytes; imports are
one undo step each, like the import commands.
- A session is saved as ONE file, a bundle `<name>.scraftz`: a zip (stored entries) holding the
`.scraft` and its `Audio Files/` as 32-bit float WAV — the same layout as a session folder, since
the browser has no folders. `io::open_session_bytes` opens a bundle or a bare `.scraft` (its audio
is then reported missing). Bundles are hostile input: entry count and size are capped, and
declared sizes are never read past.
- Every single-file output (save, Save As Template, bounce, MIDI/score/text export, track presets)
goes through `io::write_file`: a file natively, a download in the browser via a delivery hook
(`io::set_deliver`) that only the web app installs.
- In the browser Save As asks for the bundle's name (`<name>.scraftz`) instead of a folder path.
Native builds keep writing files and session folders as before. Folder outputs (stems, clip
exports) are still not available on the web.
Pairs with #157 (Save on a never-saved session opens Save As), which the browser needs too: the
web app always starts on the unsaved demo.
New dependency: `zip` (MIT), default features off (stored entries only).
Checked: engine tests for the bundle round trip (every sample identical), same-named sources,
bare sessions and hostile bundles; `cargo xtask ci`. In Chromium on the packaged web build:
⌘⇧I picks and imports a WAV; WAV, MP3, FLAC, OGG, M4A, AIFF and MIDI import by drop; Save As →
`Midnight Groove.scraftz` downloads with the imported audio inside; that bundle reopens in a fresh
page with all tracks and no missing media, and saves again with the same content.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Progress docs to the craftrules standard; full re-measure against Pro Tools 2026.4.1
Stage: alpha. Menu breadth 482/512 (94.1%, measured), feature breadth ~70%,
ready for real work ~40%; ~350-570 agent-hours to beta, ~950-1,450 to full parity,
calibrated on this repo's PR history.
- ROADMAP.md rewritten: stage banner, two numbers, dimension/feature/language tables
- docs/target-app-parity.md: authoritative assessment (area table moved from ROADMAP)
- docs/gaps.md: 22 ranked gaps with evidence and estimates
- docs/roadmap.md: milestones, current focus, beta plan
- docs/ui-, file-format-, hardware-, plugin-, localization-parity.md: new checklists
- docs/parity.md -> docs/parity-checklist.md (generated by cargo xtask parity)
- architecture.md: video, vst3-host, au-host crates; device I/O limits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Stage: pre-alpha under the core-workflow gate
Add the alpha gate to docs/roadmap.md: interface recording at a chosen buffer
size, MIDI recording, and session interchange with Pro Tools fail, so the stage
moves from alpha to pre-alpha (~5 points and ~110-180 agent-hours to alpha).
Mark the alpha blockers in docs/gaps.md; update ROADMAP.md, target-app-parity.md
and README.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add mainstream-practitioner and essentials-user readiness numbers
Mainstream practitioner ~36% (weekly-area depth 50.6% x 0.90 interaction x 0.92
stability x 0.85 file exchange) and essentials user ~57% (core-feature depth
72.4% x 0.90 launch/stability x 0.90 discoverability x 0.97 files), with written
weights, discounts and GitHub user evidence. Full ready-for-real-work checked
against its written dimension weights (40.6%); unchanged at ~40%, stage unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Readiness by audience: hours to ~95% for full, mainstream and essentials
Full ~40% / 950-1,450 h, mainstream ~36% / 380-600 h, essentials ~57% / 110-200 h,
calibrated on this repo's PR history. The full number is already an additive
weighted sum over the written dimension table; method unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Cmd+S and File > Save Session on an untitled session only left "no path
yet: pass `path` (Save As)" in the status line. From a menu, the command
palette or the keyboard, Save now opens the Save Session As prompt until
the session has a file. Calls from the control channel, MCP and the CLI
still need a `path`.
The prompt suggests ~/<name>/<name>.scraft, but saving a session without
audio there failed with "No such file or directory", because only
writing audio created the session folder. Saving now creates the folder.
On macOS, Heal Separation (Cmd+H) and Copy to Send (Cmd+Alt+H) never
ran (#140). The system menu bar used muda's predefined Hide and Hide
Others items, which carry those keys, and macOS offers a key press to
the menu bar's key equivalents before the window, so egui never saw
them: Cmd+H hid SoundCraft and Cmd+Alt+H hid the other apps.
Pro Tools uses the same keys for Heal Separation and Copy Automation to
Send, so the keys stay and Hide loses its shortcut instead. Hide
SoundCraft and Hide Others are now plain menu items that call
NSApplication's hide: and hideOtherApplications:. These are safe
objc2-app-kit calls, and both crates were already in the build through
muda. Show All keeps muda's item, which has no shortcut. Windows and
Linux have no such menu, so nothing changes there.
Fixes#144. Unless Options::zoom_with_keyboard is off, egui zooms the
whole interface on Cmd+= (or Cmd+Plus), Cmd+- and Cmd+0. It checks for
those keys at the end of every frame, after SoundCraft's shortcuts have
read them without consuming them. So every Cmd+= that switched between
the Mix and Edit windows also zoomed in by another 10 %, and Cmd+0
(Quantize to Grid) reset the interface scale. Ctrl did the same on
Windows and Linux.
Turn egui's keyboard zoom off. The Interface scale slider in Setup >
Appearance is now the only thing that sizes the interface, so it always
shows the real scale. Before, keyboard zoom changed the scale without
moving the slider, and could go past its 75-150 % range.
Dragging that slider rescaled the whole interface on every frame of the
drag. Each rescale moved the slider relative to the pointer, which
stays put on screen, so the next frame read another value under the
pointer. The scale jumped back and forth (105, 110, 105 ... 145, 120,
150, 90 % in one drag) and the slider would not stay where it was
dragged. Apply the scale once the mouse button is released. It is now
applied after the window has drawn, too, so a click, Reset or an arrow
key takes effect in the same frame instead of waiting for the next
input.
Agents that only check isError were treating partial failures as success. Also reject a non-array calls value instead of silently running nothing. Fixes#145.
Export Clips as Files had no shortcut and no menu entry, so Shift+Cmd+K,
its key in Pro Tools, did nothing (#7). It now opens the command's
folder prompt, and OK exports the selected clips there.
Trying it showed three more gaps:
- Browse… in a folder prompt opened an audio-file picker, so no folder
could be chosen. Folder prompts (Export Clips, Bounce Stems) now use a
folder picker, a new `pick_folder` host service; the web build has
none and keeps the text field.
- A successful export said nothing. The status bar now reads "Exported
N clip(s) to <folder>", and the command returns the folder.
- With nothing selected the export succeeded and wrote no files. It now
fails with "no clips selected".
The shortcut lookup moves into `command_for` so a test can check what a
key resolves to.
The command cleared every automation lane in the selection and left the clipboard empty, so the cut could not be pasted. It now uses the same copy-and-clear path as Cut Pan Automation.
engine.execute and app.run of ui.new_tracks_dialog and ui.bounce_dialog were opening modal dialogs, so a script could not create tracks or bounce without a window. Those ids now return an error unless the menu invokes them.
The bounce probe read aux latency before the aux had processed, so a lookahead compressor shifted the export and dropped audio at the end of the range.
Clip gain and clip effects renders wrote the clip as it already sounded and left the old ratio in place, so the new file was stretched a second time and its tail went silent.
Both processes rendered a unity-gain file and then cleared the clip's gain and envelope, so the result was quieter than the clip the user heard. Invert and Duplicate now leave that gain on the new clip. Processes that bake a new level still write a unity-gain clip.
Half-Speed Playback stored 0.5 on the player, and a manual Stop left that rate in place, so the next ordinary Play stayed at half speed. Stopping now resets the player to 1.0. Half-speed itself still plays at 0.5, and a pass that ends on its own still returns to full speed.
The command wrote the lane onto the whole clip and cleared points only inside the selection, so the old curve still played and the clip got quieter on both sides. The selection now holds the fader, with anchors so the surrounding curve stays put. Volume and clip-gain windows take their end value from the last sample inside the window, so a breakpoint on the next sample does not ramp the audio before it.
app used to leave every ui., engine., session., and app. method untouched, so engine.history, ui.theme, and session.new were sent as unknown control methods. Only the real control-channel methods stay methods. Every other dotted id is engine.execute.
outln! treated a failed write as success and returned immediately, so run and script stopped before --bounce or --save whenever a pipe reader had gone away. A write error is now ignored and the exit code comes from the work itself.
Export Clips as Files wrote every clip to {name}.wav, so the second clip replaced the first. A name that is already in the destination now gets a -2, -3, … suffix.
Snap to Next and Snap to Previous ignored other selected clips when looking for a destination, so a multi-clip selection piled onto the same later clip or left the gap between the selected clips in place.
The menu bar rebuilt and leaked a fresh alias string for every mix-view section on each redraw. Those fifteen paths are now created the first time the menu catalog is asked for them.
event.tempo_stretch accepts factor or length, and the Apply button was sending end_bpm, which the command ignored. A lower end tempo lengthens the selection, so the factor is the start BPM divided by the end BPM.
The bus row called mix.bus_rename and mix.bus_delete, which are not commands, so both buttons failed. setup.io already renames and deletes a bus by name.
A third built-in instrument next to Subtractive Synth and Drum Synth. It plays one
sample across the keyboard: root note, transpose and fine tune, key tracking, key and
velocity ranges, amp ADSR with velocity sensitivity, sample start, forward or ping-pong
loops, gate or one-shot playback, 32 voices with cubic interpolation. The sample's own
rate is converted as it plays.
- dsp: `Plugin::set_sample` and the `SampleSource` trait hand an instrument shared
audio without copying; the Adsr from the synth is reused.
- model: `Insert::sample` names the session source the sampler plays.
- mix: sync hands the pool audio to the instrument when it changes, and built-in
instruments now get their parameter values applied (they always ran on defaults).
- engine: `mix.sampler_load {track?, path, params?}` decodes WAV, AIFF, FLAC, MP3, OGG
(anything file.import_audio reads) into a session source, resampled to the session
rate and saved in Audio Files with the session; `mix.sampler_clear`; and
`mix.instrument_param {track?, param, value}` for any built-in instrument.
Tests generate their audio in code; no bundled assets.
Ctrl+Shift+Up/Down (#76) only worked on macOS. The registry's Ctrl there
is the Mac Control key, which has no key of its own on Windows and Linux
(#20), so the keys did nothing there, while the Shortcuts window and the
command palette still showed Ctrl+Shift+Up.
Pro Tools for Windows puts these on the Start key, but egui does not
report that key off macOS, and Windows and GNOME use Win/Super+Shift+
Up/Down to resize and move windows. So off macOS the nudge is
Ctrl+Shift+Up/Down: the same keys as on the Mac and what the labels
already say. No SoundCraft shortcut uses them there; Ctrl+Up/Down without
Shift still extends the selection.
Resolve System appearance from egui on every frame and fall back to Dark when the integration cannot report a theme. Keep receiving OS changes during manual modes and apply the selected palette to both widget style branches and the current root UI.
Remove the cached detector and its unused dependency. Cover startup, live transitions, manual overrides, saved System preferences, and user colors with an application-frame regression. Add reproducible offscreen system-theme inputs and document the fallback.
The input callback appended every block to one growing Vec: it
reallocated on the audio thread as the take grew, kept every input
channel in RAM, and the take existed only in memory until Stop, so a
crash during a take lost it.
- The input callback copies each block into a preallocated lock-free
ring (atomic slots, no unsafe) and never locks or allocates. Samples
that don't fit are paid back as silence before the next block, so the
take keeps its timing; the loss is reported in the status line.
- A writer thread drains the ring every 10 ms into one 32-bit float WAV
per armed track, holding only that track's input channels. Files go
to the session's Audio Files folder, or to its autosave folder while
the session has never been saved, named like the incumbent's takes
(<track>_<NN>.wav). An existing file is never overwritten.
- The header is rewritten every second, so a file left by a crash plays
up to the last update. Above 4 GiB it switches to RF64 in place.
- If a take file can't be created, Record doesn't start and says why;
a write error ends that take where it failed.
- At Stop the files become the takes' sources. Loop recording keeps one
file per track, one clip per pass (at most 1000) on its own playlist,
in one undo step.
- Saving uses a file already in the target's Audio Files folder in
place instead of rewriting it, so neither Save nor autosave copies a
take. A take recorded at another rate than the session's is saved once
at the session's rate.
soundcraft-audio-io gains wav_float_header (its length doesn't depend
on the frame count, so it can be rewritten in place); encode shares the
header code. Splitting a loop take into passes moved from the UI into
soundcraft_engine::io::add_recording, where it is tested.
Nothing in the Clip List could be removed: neither its files nor its clips
had a delete action, so everything stayed listed.
- New `clip.clear` (Cmd+Shift+B, the Clip List Clear shortcut in Pro Tools),
also offered as Clear… on every Clip List row's right-click menu. A clip
leaves its track; a file leaves the session with its decoded audio and is
never deleted from disk. One undo step brings everything back.
- As in Pro Tools, Clear asks first. The dialog names what goes, including
the clips on any track or playlist that still play a file being removed:
they are removed too. Programmatic calls never open it; they pass
`with_clips: true` to remove a file still in use, and fail without
changing anything otherwise.
- File rows can be selected now (`edit.select {sources}`, kept in
`EditState::selected_sources`); with no ids, `clip.clear` acts on the
Clip List selection.
- Remove Warp and re-warping ignore a stored original whose file has been
cleared, so they never point a clip at a missing file.
* Add a selectable Light theme
* Apply the active theme to the menu bar
* Use the system theme by default
* Expose Appearance in the Setup menu
* Add Light theme screenshot
* Remove the Light theme screenshot
Repo policy keeps large binary assets out of the tree, and the PNG had no
ATTRIBUTION row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Appearance: ui.theme command, Dark default, non-blocking system lookup
- ThemeMode derives Default with Dark as the default (fixes clippy's
derivable_impls); System and Light are opt-in. LIGHT_THEME starts false.
- New UI command `ui.theme {"mode": "system|light|dark"}` (label
"Appearance") in UI_COMMANDS; with no mode it reports the current one.
The UI Customization combo box dispatches it instead of writing state.
- The choice lives in UiState.theme, so it persists with the existing UI
prefs (ui.json) and is exposed through ui.set / ui.inspect. Restoring a
window configuration keeps the current theme.
- dark_light::detect() is only called in System mode, on a background
thread (an AtomicU8 holds the answer; dark until it arrives, then a
repaint). On the web it is the synchronous matchMedia lookup.
- theme::apply runs only when the effective palette changes, not every
frame.
- window.ui_customization keeps its catalog path (Window > UI
Customization) so parity is unchanged; Setup > Appearance still opens
it. The combo row is labelled "Theme" so it no longer repeats the title.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Light theme: move hard-coded colours to theme tokens
The Light palette left dark islands and unreadable text where colours
were Color32 literals. New tokens (Dark values are the old literals, so
the Dark theme renders pixel-identical to main):
- menu bar; toolbar group outline; edit-mode cells (no more dark block
around Shuffle/Spot/Slip/Grid); counter panel outlines, dividers and
off toggles; Smart-tool bracket
- selected rows in the Tracks/Groups/Clips lists, track-visible dot,
selected Edit track header, selected Mix strip
- marker names, alternate-playlist lanes, Universe background/outline,
Big Counter background
- meter background and clip lamp, pan knob body/ring/pointer, Mix insert
and send slots, Mix section captions
In Light, clip bodies are a paler tint and waveforms a deep shade of the
clip colour instead of black.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Light theme: instrument slot and plugin window text use the current tokens
The instrument picker (#23) landed with Tokens::DARK literals.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
There was no way to change a clip's gain from the UI (#2): clip gain is
stored, drawn and settable through `clip.gain`, but no menu item, key or
gesture reached it. This adds the two ways Pro Tools offers.
Keyboard: Pro Tools nudges the selected clips' gain with
Control+Shift+Up/Down, 1 dB a step by default. Two new commands, Nudge
Clip Gain Up and Down, do the same through `clip.gain`'s relative
`delta_db`, so its -144..+36 dB range holds and each nudge is one undo
step. Control+Up/Down extended the edit selection whether or not Shift
was held, which swallowed the new keys; it now leaves the Shift variants
to the command registry.
Fader: in Pro Tools, Clip Gain Info puts a fader icon and the gain value
in the bottom-left of each clip, and pressing the icon pops up a small
fader. With View > Clip > Clip Gain Info on, or once a clip has gain,
audio clips now show the icon and the value, and dragging the icon moves
the clip's gain from -144 to +36 dB (Cmd for fine moves), one undo step
per drag. The icon is drawn in code. The fader follows the pointer from
where it was pressed rather than adding up per-frame deltas, which lose
the first few pixels while egui decides between a click and a drag and
leave the fader out of step with the pointer once it hits an end (it
would stick, then jump).
Clip Gain Info no longer depends on Clip Gain Line being shown, since
the two are separate views in Pro Tools, and the value moved from the
clip's bottom-right to beside the icon. Dragging the icon of a clip when
nothing is selected selects that clip first, because `clip.gain` needs a
selection.
Agents using session.inspect / inspect_session can tell missing media from metadata alone, complementing open_session missing lists and audio_health. Also credit Mawen in people.toml.
The window checks every playlist, including inactive takes, for missing audio, rate mismatches, empty buffers, and clips that extend past their source.
- A muda error now logs a warning and keeps the in-window menu bar
instead of aborting startup (never crash).
- sync() walked ~500 leaves with a command lookup and a Cocoa call each
frame; run it at most 4x/s and right after a click.
- About / Session Info from the application menu open their window
rather than toggling it closed.
- The title carries Edit/Mix and the dirty mark only when the system
menu bar replaces the egui one (no duplicate on Linux/Windows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui panics on Type 1 or bitmap font bytes, which fc-match can return on
minimal systems, so check the sfnt magic first. Mask the variable-font
named instance out of %{index}. Fix the unused 'bold' warning on macOS
and Windows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stopped position does not advance, so a beat at the stop point
re-rendered its first 30 ms every block: a ~94 Hz buzz whenever a track
was record-armed or monitoring input. Regression test included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stop no longer writes the session's scroll directly (everything is a
command) and leaves the view alone when the stopped position is visible.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On macOS a held Cmd made the Ctrl state ignored, so Cmd+Ctrl+X ran Cut
instead of Cut Clip Gain. Ask egui for the OS so the web build in a Mac
browser uses Mac modifiers too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
VST3 editor windows are hosted on macOS only, but editor_main_thread and
fixture_e2e asserted a probed view size on every platform, failing the
FreeBSD job on every commit to main (and Linux locally). Gate only the
size assertion; the controller round trips still run everywhere.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cpal (wasm) creates its output stream at startup, before any user gesture, so the browser leaves the WebAudio AudioContext 'suspended'. As a result no audio plays, and the transport clock never advances because the audio callback drives it.
Wrap the AudioContext constructor before the wasm module loads so that every context cpal creates is captured. On the first pointer, mouse, touch or key event, resume any context that is still suspended.
Signed-off-by: Ahmed Moussa <moussa.ahmed95@gmail.com>
The DMG opened as a plain Finder window. It now shows the app icon's figure on
the SoundCraft colour, with SoundCraft.app and the Applications link side by side.
Same approach as PhotoCraft's and VectorCraft's (storytold/photocraft#1201,
storytold/vectorcraft#493).
- packaging/macos/dmg/: background.svg (source; text outlined, no paper grain,
the figure an <image> of assets/app-icon), background.tiff (1x + 2x HiDPI,
16-colour palette, Deflate, sRGB; ~28 KB)
and dmg-layout.DS_Store (Finder view settings: window, icon size 128,
positions, background).
- generate.py writes the TIFF and the layout on any OS, byte-for-byte
reproducible, with nothing from the machine that ran it (no paths, users,
disk names or volume UUIDs).
- package.sh copies them into the staging folder; the image is still built with
hdiutil makehybrid. Nothing extra runs in the signing job.
- The volume name drops the version: the layout finds the background through
an alias that includes the volume name. The DMG file name keeps the version.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eframe's default wgpu instance enables PRIMARY | GL on Windows. Creating
the OpenGL backend can crash inside AMD's atio6axx.dll before the window
appears, so the app exits silently at launch. When WGPU_BACKEND is not
set, the Windows instance now enables only DirectX 12; an explicit
WGPU_BACKEND is still honoured. macOS, Linux and web are unchanged.
Same fix as storytold/designcraft#168 and storytold/filmcraft#179.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyVbT6Vv4BwWZ4hY8973qD
The clip menu was attached at the end of lane_interaction, which returns
early when the lane has no pointer position. Once the menu opened, the
pointer sat over the popup, the lane lost its hover, and the menu was
never drawn again, so it vanished before Delete could be clicked.
Attach the menu before the early return and remember the right-clicked
clip so it keeps its target. Stale clip ids are dropped if the clip no
longer exists. Adds a kittest regression test covering right-click,
move onto the menu, and Delete.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The monitor ring fed raw input-device samples to the output callback as if
they were at the session rate. A 16 kHz or 44.1 kHz input played back too
fast (higher pitch) and ran dry between bursts, so it sounded quiet.
InputRing now knows the input rate and converts to the session rate with
linear interpolation in pop_into, allocation-free and non-blocking. The
buffer cap is computed once in new(). Tests cover rate up-conversion,
down-conversion, equal rates and underrun.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The tempo-change dialog clamped BPM to 1..=999, but the engine accepts
5..=1000. A 1000 BPM tempo was silently changed to 999 on OK, and values
below 5 were rejected after the dialog had closed.
Use the engine's limits, and move the OK action into tempo_change_action
so a test can check it runs a real command with the position. The test
would have caught the original bad command id.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Options > Click set a flag but nothing rendered sound, so a click track
was silent. The mix engine now synthesizes a short blip on every beat of
the tempo map (accent on beat 1), using the click.* settings from
Setup > Click/Countoff, and sums it after the master fader.
The metronome is opt-in per engine: only live playback enables it.
Offline renders (bounces, stems, exports) leave it off so the click is
never printed into a file.
Also: midi_to_hz moves from the engine demo into soundcraft-dsp so the
mix and demo share one tuning formula.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Double-clicking the tempo ruler opened a number dialog whose command was
built as "event.tempo@<position>". No such command exists, so OK failed
and nothing changed. The position was also never passed, so a change
would have landed at the session start.
Add a TempoChange dialog that carries the position and runs
event.tempo with bpm and at. Add an engine test that a tempo change at a
position is applied there.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
There was no way in the app to choose a CLAP, VST3 or Audio Units instrument,
or to open an instrument's editor:
- the insert menus leave instruments out;
- an instrument was only set by track.new;
- the Mix window's INSTRUMENT row looked for instrument inserts rather than the
track's instrument;
- plugin windows opened only for insert slots.
Changes:
- mix.instrument {track?, plugin} sets an instrument track's instrument, with
the plugin's default parameters, and is undoable. It refuses effects and
other track kinds.
- The INST label on an instrument track's header opens an instrument menu: the
current instrument and its editor, then the built-in, CLAP, VST3 and Audio
Units instruments, by vendor.
- The Mix window's INSTRUMENT row shows the track's instrument. A click opens a
hosted instrument's plugin window, and a right-click changes it.
- Plugin windows open for the instrument slot, with only the editor button,
because the bypass, preset and parameter commands address insert slots.
They no longer derive a slot letter for the instrument slot.
Closes#22
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Mix window's insert menu listed each format's plugins as one flat column.
With many installed (53 VST3 and 95 Audio Units effects on a composer's Mac)
it ran past the bottom of the screen and egui menus don't scroll, so the lower
entries couldn't be reached. CLAP, VST3 and Audio Units now open one submenu
per vendor, as Pro Tools does when plug-in menus are organized by manufacturer,
and every list scrolls within the screen.
Choosing an Audio Unit did nothing: the engine's plugin lookup, which
mix.insert uses, covered built-in, CLAP and VST3 plugins only, so "au:" ids were
"unknown plugin" and the UI dropped the error. The lookup now includes Audio
Units (the engine depends on soundcraft-au-host, like the mixer and the UI), and
the menu shows a failed insert in the status bar.
Closes#21
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The system Quit (⌘Q, or a quit Apple Event) goes through
-[NSApplication terminate:], which calls exit() from inside the event loop, so
eframe::run_native never returns and the shutdown placed after it never ran.
With HALion Sonic in use, the exit handler then ran its bundleExit too late and
the app crashed on quit ("quit unexpectedly", found in manual testing).
eframe calls on_exit on every way out, so the app now releases its player (and
with it the plugin instances) and shuts plugin hosting down there.
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Testing on macOS showed that the exit-handler fallback comes too late for a
plugin that has been used: with HALion Sonic inserted on a track, its bundleExit
segfaults, and both the app (on quit) and soundcraft-cli (after a bounce) exited
with 139. A plain scan was fine. Exit handlers run in reverse order of
registration, so state the plugin set up while in use is presumably torn down
before the host's handler runs.
soundcraft_vst3_host::shutdown() runs the module exits up front, and
soundcraft_engine::shutdown_plugin_hosts() calls it. The app calls that once the
event loop has returned (the player and its plugin instances are gone by then),
and the CLI once its subcommand has. The exit handler stays as the fallback for
other exits, such as test binaries.
The fixture can now set up state lazily the way such plugins do
(VST3_FIXTURE_LATE_STATE), and module_exit_shutdown checks both sides in child
processes: with shutdown() the exit is clean, with only the exit handler the
fixture reports the late teardown. Contract violations now end the process with
status 3 instead of an abort, so that expected failure leaves no crash report.
Refs #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui reports a held Ctrl as both ctrl and command off macOS, so no Cmd+ shortcut matched and Ctrl+S opened Search (Cmd+Ctrl+S) instead of saving. Off macOS a held Ctrl now stands for the shortcut's Cmd, shortcuts that also need the Mac Control key stay off the keyboard there (Ctrl+X is Cut, not Cmd+Ctrl+X), and the Search chord is macOS only.
vst3-host ran each module's entry (bundleEntry / InitDll / ModuleEntry) when it
loaded a .vst3 and cached the module for the life of the process, but never ran
the matching exit or released the cached factory. At exit() the module's static
destructors then ran on a module that was never shut down. Steinberg's HALion
Sonic segfaults there, so every process that had scanned VST3 plugins crashed on
exit: the app on quit, soundcraft-cli, and the engine, mix and vst3-host unit
tests.
At process exit each module now has its factory released and its exit run,
newest first, on the thread that ran its entry: Native Instruments' Massive X
crashes in bundleExit on any other thread. Loads asked for off the main thread
go to a long-lived module thread, which also runs their exits. Modules with a
live instance or a leaked editor view are left alone, later loads are refused,
and plugin code stays mapped. The exit handler is registered with atexit after
each new module, so it runs ahead of that module's static destructors; it uses
OS thread ids because std::thread::current() panics in exit handlers. A module
whose entry ran but that then fails to load now gets its exit too.
The fixture plugin now checks the module lifecycle and aborts the process when
it is broken. Three harness = false tests cover the exit after use, a live
instance at exit, and a load from a short-lived thread.
Fixes#16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: the engine's panic report, audio devices
that failed to open or broke, plugins that refused their stored state or
failed to activate, CLAP plugins' own log messages, files that failed to
open. A launch from a desktop menu has no terminal either, so there was
nothing to attach to a bug report.
apps/soundcraft/src/logging.rs (no new dependency):
- standard error plus <settings dir>/logs/soundcraft.log (beside
ui.json, Autosave/ and Presets/); each launch rotates the previous log
to soundcraft.1.log and .2.log, so the log of a run that crashed
survives the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
(bounded) and written once the file is attached
- default levels: info for soundcraft*, warn for everything else;
RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
error as the only sink; a poisoned lock is tolerated
- runs with SOUNDCRAFT_NO_PREFS (agents' test runs) log to standard
error only, so they don't rotate away the user's own logs
Realtime audio: a record takes a lock and does I/O, which the audio
callback must never do, yet code on the audio thread logs (the cpal
error callbacks, the synth's full event queue, a hosted plugin's failed
process, a CLAP plugin calling the host's log, the panic hook). The cpal
callbacks now mark their thread (soundcraft_mix::mark_audio_thread,
re-exported by soundcraft_playback), and the mix engine carries the mark
into the rayon workers that render strips for it. A record from a marked
thread is not written there: the first is kept in a fixed buffer
(try_lock, no allocation, never waits), the rest are counted, and the UI
thread writes them every frame (any other thread before its own next
record).
The logger is installed first in main (before the engine's panic hook)
and the file is opened after argument parsing (--version leaves no
file). The settings directory moves out of prefs_path into config_dir.
The control server's eprintln!s go through the logger (the
SOUNDCRAFT_CONTROL_PORT line on standard output stays). Documented in
README.md (Getting started > Logs) and docs/architecture.md (Audio).
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
# 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.
# 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.
# 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.
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# 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.
@@ -6,7 +6,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
2. Read `ROADMAP.md` (committed) for status, milestones and the parity estimate.
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
## ⚠️ Assets: the absolute rule
@@ -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`)
@@ -51,7 +52,7 @@ Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin: panels read e
## Everything is a command
User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (windows, dialogs) live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). Menus, shortcuts, the CLI, the control channel and MCP all dispatch the same ids. Programmatic calls never open dialogs; only menu-style invocation (`ui.menu.invoke`) does. New commands go in a new module with its own `specs()`, registered by one line in `cmd/mod.rs`.
Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity.md`; a test enforces a floor that only rises.
Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity-checklist.md` (generated; the hand-written assessment is `docs/target-app-parity.md`); a test enforces a floor that only rises.
## Running and looking at the app
-`cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port is printed).
@@ -66,10 +67,10 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc
`cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit.
## Release
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak manifest), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone lands.
`ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry.
SoundCraft bundles no font files. It uses the default fonts shipped inside the `egui` crate
(Ubuntu-Light: Ubuntu Font Licence 1.0; Hack: MIT; Noto Emoji: OFL-1.1; emoji-icon-font: OFL-1.1/MIT)
(Ubuntu-Light: Ubuntu Font Licence 1.0; Hack: MIT; Noto Emoji: OFL-1.1; emoji-icon-font: MIT)
and, at runtime, the operating system's own UI fonts when available (never redistributed).
## Bundled egui font icons
These dependency fonts are embedded by the pinned egui font definitions; no font file is added
here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts.
| Asset | Author | Source | Licence |
|---|---|---|---|
| `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in NOTICE) |
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in NOTICE) |
## Files
| Path | Author | Source | License |
@@ -38,9 +48,11 @@ and, at runtime, the operating system's own UI fonts when available (never redis
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-macos-512.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft.icns` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft.ico` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/hicolor/` | SoundCraft contributors | original, rendered from `icon.svg` (16–512 px PNGs and the scalable SVG) | MIT OR Apache-2.0 |
| `docs/images/` | SoundCraft contributors | screenshots of SoundCraft rendered from the synthesised demo session | MIT OR Apache-2.0 |
| `crates/audio-io/tests/fixtures/` | SoundCraft contributors | original test tones and silence generated with ffmpeg (sine) or by hand; commands in `crates/audio-io/tests/fixtures/README.md` | MIT OR Apache-2.0 |
No font file is copied into this repository. The full upstream notices follow.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Noto Emoji font (dependency asset)
----------------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
embeds Hack-Regular.ttf as the default monospace fallback. The upstream notice and
licences follow unchanged.
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
The work in the DejaVu project was committed to the public domain.
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
MIT License
Copyright (c) 2018 Source Foundry Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BITSTREAM VERA LICENSE
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
<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>
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
new tracks. **Setup › Keyboard Shortcuts** lists them all.
### Logs
The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
settings directory, beside `ui.json`, `Autosave/` and `Presets/` (Linux `~/.config/soundcraft/logs/`,
or `$XDG_CONFIG_HOME/soundcraft/logs/`; macOS `~/Library/Application Support/SoundCraft/logs/`;
Windows `%APPDATA%\SoundCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so
this file is what to attach to a bug report: the engine's panic report, audio devices that failed to
open or broke, plugins that refused their stored state, CLAP plugins' own messages and files that
failed to open land there. Each launch moves the previous log to `soundcraft.1.log` (and that one to
`soundcraft.2.log`), so the log of a run that crashed survives the next start. The file stops
growing at 16 MiB. `--version` writes no file, and runs with `SOUNDCRAFT_NO_PREFS` log to standard
error only.
| Variable | Effect |
|---|---|
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for SoundCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,soundcraft_mix=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`soundcraft*=debug`). |
| `SOUNDCRAFT_NO_PREFS` | `1`: no preferences read or written and no log file (agents' test runs). |
The realtime audio thread never writes a record itself: the logger keeps the first one it logs
(without waiting or allocating), counts the rest, and the UI thread writes them. The logger is
`apps/soundcraft/src/logging.rs`. The web app logs to the browser console instead.
## For agents and scripts
Everything you can click is also a command with an id and JSON parameters, and the same commands
claude mcp add soundcraft -- soundcraft-cli mcp --demo
# Or keep the agent to some folders (reads and writes are separate roots).
soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
```
See [`docs/control-protocol.md`](docs/control-protocol.md) and [`docs/mcp.md`](docs/mcp.md).
`soundcraft-cli commands` lists every command.
See [`docs/cli.md`](docs/cli.md) for the command-line tool (subcommands, exit codes, environment
variables, man pages and completions), [`docs/control-protocol.md`](docs/control-protocol.md) and
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command.
Open **SoundCraft › Session Audio Health** to check loaded audio availability, sample-rate
mismatches and clip source bounds across all playlists, including alternate takes. The report
offers a refresh button and suggests how to resolve each issue. Scripts can obtain the same JSON
report with `soundcraft-cli run --demo --cmd 'session.audio_health={}'`. This checks loaded media,
not files on disk, signal levels, plugins or routing.
## How it's built
@@ -208,15 +250,20 @@ and layering checks and the WebAssembly build.
## Status and roadmap
SoundCraft is **pre-alpha, and close to its first alpha**: it covers 94 % of the incumbent's menu
items and roughly two thirds of its features in depth. The core works and is fun to use, and plenty is still missing. The
honest status, what's next and our effort estimates are in [`ROADMAP.md`](ROADMAP.md); the
menu-by-menu comparison is in [`docs/parity.md`](docs/parity.md). Bug reports and wish lists are
SoundCraft is **pre-alpha**: it covers 94 % of the incumbent's menu items (measured), about 70 % of
its features by presence, and is roughly 40 % of the way to replacing it for real work: record,
edit, mix and bounce work end to end, but choosing an audio interface and buffer size,
recording MIDI from a controller and session interchange (AAF/OMF) are still missing, and those
are the alpha checklist. Translations are missing too. The status, the stage and our effort
estimates are in [`ROADMAP.md`](ROADMAP.md); the full assessment is in
[`docs/target-app-parity.md`](docs/target-app-parity.md), the ranked list of what's missing in
[`docs/gaps.md`](docs/gaps.md), and the menu-by-menu comparison in
[`docs/parity-checklist.md`](docs/parity-checklist.md). Bug reports and wish lists are
very welcome, in the issues or on Discord.
## Downloads
**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) have earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
@@ -228,6 +275,25 @@ very welcome, in the issues or on Discord.
Installers and executables are code-signed.
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
PowerShell, from the folder containing the executable:
```powershell
$env:WGPU_BACKEND="vulkan"
&.\soundcraft.exe
Remove-ItemEnv:WGPU_BACKEND# restore the default for later launches
```
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or
`dxil.dll` is needed ("Parent device is lost" at start, #185). `$env:WGPU_DX12_COMPILER = "dxc"`
(or `"auto"`) selects wgpu's DXC handling again for troubleshooting.
### macOS
| Build | File | Notes |
@@ -245,6 +311,8 @@ Installers and executables are code-signed.
RISC-V (riscv64): `soundcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (glibc 2.39+, e.g. Ubuntu 24.04).
### FreeBSD
| Build | File |
@@ -315,11 +383,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>
<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 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.
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
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 file is
the honest status: what works today, what is missing, and how far we are.
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
the one-page summary; the detail is in [`docs/target-app-parity.md`](docs/target-app-parity.md)
(assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md)
(milestones and beta plan) and [`docs/parity-checklist.md`](docs/parity-checklist.md) (generated
menu checklist).
## Where we are (2026-10-07)
## Headline numbers
| Measure | Value | How |
| Number | Value | Kind |
|---|---|---|
| Menu-catalog parity (engine + UI) | **482 / 512 menu items (94 %)**; engine alone 397 / 512, see [`docs/parity.md`](docs/parity.md) | `cargo xtask parity` compares the incumbent's 512 menu leaves (names observed black-box) with our command registry. Most of what is left is Avid's online services (sign-in, cloud projects, collaboration, Splice, Learn), Dolby Atmos-specific items, HEAT and ARA |
| Estimated overall feature parity | **~65 %** | judgement across the areas below, weighted by how much working engineers rely on them. Menu items are only the surface; depth (editing feel, plugin catalogue, MIDI and score editing, video) is where the gap is |
| Distance to an **alpha** | **~85 % there, ~12–18 wall-clock hours** of Claude Opus 5.5 work | see [Alpha](#alpha) below |
| Distance to **100 % parity** | **~110–150 wall-clock hours** of Claude Opus 5.5 work with a single agent; roughly 40–55 hours with 3–4 agents in parallel (the last three features in this session were built in parallel this way) | sum of the per-area estimates below |
| Menu breadth | **482 / 512 Pro Tools menu leaves (94.1 %)**; engine alone 397 / 512 | measured (`cargo xtask parity` + UI-layer aliases) |
| Audio file I/O | WAV/BWF/RF64, AIFF/AIFC, FLAC (read/write); MP3, OGG, AAC, ALAC, CAF (read); peaks. Whole files are decoded into memory: disk streaming for very long sessions is still to do | 70 % | 12 |
| Editing | Slip/Shuffle/Spot/Grid, cut/copy/paste/clear/duplicate/repeat/shift, Paste Special (merge MIDI, markers, clip gain/effects), separate/heal/trim/consolidate/strip silence, fades & crossfades, nudge, playlists with comping, every tool gesture, edit groups, clip groups (and `.scgrp` import/export) | 75 % | 20 |
| Automation | Breakpoint lanes for volume/pan/mute/sends/plugin params, trim automation; live Write/Touch/Latch passes; write/thin/glide/convert/coalesce. Surround-pan automation is still to do | 60 % | 15 |
| Plugins (built-in) | 26 original processors + 2 instruments, AudioSuite offline processing for the whole AudioSuite menu | 45 % | 30 |
| Third-party plugins | CLAP, VST3 and Audio Units (macOS): audio, parameters, latency, notes, state saved in sessions, instances created/destroyed off the audio thread, editors (floating CLAP GUIs, VST3 views on macOS, verified with Melodyne). Missing: AU editors, embedded CLAP GUIs, VST3 editors on Windows/Linux, ARA | 80 % | 10 |
| Recording | Input capture, punch in/out (selection, on the fly, pre/post-roll), loop record into playlists, input monitoring through the channel strip, autosave and recovery | 60 % | 10 |
| Elastic Audio / TCE / Beat Detective | Pitch-preserving warp on Elastic tracks, TCE to timeline, conform to tempo, Beat Detective and Identify Beat windows; audio-to-MIDI pitch detection | 45 % | 15 |
| Surround | Main and bus formats from stereo to 9.1.6 and Ambisonics, surround panner with divergence/centre/LFE/height, ITU fold-down, multichannel bounce, Renderer window, object/bed routing | 60 % | 12 |
| Video | Picture track with thumbnails, Video and Video Universe windows, H.264 (Baseline/Main/High), ProRes (all flavours) and Motion JPEG decoders in pure Rust, sync offset, relinking. Missing: HEVC/AV1/DNx, timecode-track placement, video export | 55 % | 15 |
| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog, floating windows, dialogs (every file command has a path prompt), command palette | 70 % | 20 |
| Agent control | CLI, JSON control channel, MCP server (headless + bridged), offscreen UI renders | 90 % (ahead of the incumbent) | 3 |
| Release engineering | Signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm/tar/Flatpak, FreeBSD, Web/WASM on every push to `release` | 80 % | 3 |
Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's PRs; 60–70 % parallelizes.
- **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
- **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)
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
- **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change.
- **2026-10-12:** `soundcraft-cli convert` rejects an unparsable or zero `--rate` and an unknown `--bit-depth` (exit 1, nothing written) instead of converting with a default (#256)
- **2026-10-12:** Timecode labels with huge fields no longer overflow: frame counts are computed in 128 bits and clamp, so `setup.current_timecode` and `session.format_time` saturate instead of failing (#248)
- **2026-10-12:** `session.format_time` with the smallest bar number no longer fails with an internal error: the bar offset saturates (#249)
- **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change.
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours,
tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session.
- **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media
warnings, session audio health, malformed-JSON and encoder-extension checks in the CLI, macOS
system menu bar, Windows DX12 default, Linux fontconfig, metronome click in playback.
| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h |
| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers |
| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added |
| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list |
| 2026-10-06 | minor | Alpha and parity estimates |
md.push_str("It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.\n");
md.push_str("UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).\n\n");
md.push_str(&format!(
"**Menu items implemented by the engine: {} / {} ({}%)**\n\n| Menu | Implemented | Total |\n|---|---:|---:|\n",
md.push_str("It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.\n");
md.push_str("UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).\n\n");
md.push_str(&format!(
"**Menu items implemented by the engine: {} / {} ({}%)**\n\n| Menu | Implemented | Total |\n|---|---:|---:|\n",
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.