Compare commits

...
Author SHA1 Message Date
dependabot[bot] ccd9e63e90 ci(deps): bump storytold/craft-common-actions/.github/workflows/windows-arm64.yml
Bumps the actions group with 1 update: [storytold/craft-common-actions/.github/workflows/windows-arm64.yml](https://github.com/storytold/craft-common-actions).


Updates `storytold/craft-common-actions/.github/workflows/windows-arm64.yml` from 8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f to fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
- [Commits](https://github.com/storytold/craft-common-actions/compare/8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f...fa5ef18780cf5e7d5d3b8de068de56cffc83cac5)

---
updated-dependencies:
- dependency-name: storytold/craft-common-actions/.github/workflows/windows-arm64.yml
  dependency-version: fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
  dependency-type: direct:production
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-29 10:07:52 +00:00
Dylan M. Taylor 8ca6939981 Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#1167)
* Document the aarch64 archive name in FreeBSD packaging

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.

* Include the full header in package.sh --help
2026-10-28 15:36:07 -04:00
Dylan M. Taylor 60b2c07a70 Grant network access so update checks can reach GitHub (#1163) 2026-10-28 13:16:03 -04:00
Brandon ThomasandClaude Opus 5.5 c6befe57af Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:52:03 -04:00
Dylan M. Taylor 9f2be37e36 Enforce Rust coding standards via Clippy (#1129)
* chore(lints): apply strict workspace Clippy rules

* refactor(h264): split decoding work by operation

* refactor(h264): clarify prediction and filter inputs

* docs: require workspace lint inheritance

* refactor(audio): clarify host FFI imports and inputs

* docs: clarify crate lint placement

* refactor(audio): replace wildcard imports in I/O modules

* refactor(mix): extract strip and routing stages

* refactor(ui): split edit window event handlers

* refactor(ui): clarify app and dialog actions

* refactor(ui): simplify mixer and panel workflows

* fix(ui): apply track resize only once
2026-10-28 11:48:06 -04:00
aiongg e82c1689b3 refactor(cli): parse soundcraft-cli arguments with clap, following RFC-0012 (#1136)
* 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
2026-10-27 21:09:43 -04:00
aiongg 40f593a360 Merge pull request #1124 from Jeremy8776/feat/install-scope-policy
Windows installation policy: SoundCraft
2026-10-27 17:01:28 -04:00
aiongg 662cf85c00 ci: use storytold/craft-common-actions at the merged installer-policy commit 2026-10-27 20:51:11 +00:00
aiongg a8c19f0ffe Merge pull request #1144 from Silvertree2010/perf/export-speed
Speed up mix bounce by about 20 % with byte-identical output
2026-10-27 15:41:22 -04:00
Jeremy bf9ad66ea6 fix(installer): check the exact architecture-specific machine folder
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.
2026-10-27 19:12:44 +00:00
NetterSilverandClaude Opus 5.5 747c79e3a7 Make the new DSP and bounce pins platform-independent
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>
2026-10-27 18:33:54 +01:00
NetterSilverandClaude Opus 5.5 d293336eac Speed up the bounce hot paths without changing output
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>
2026-10-27 17:42:09 +01:00
NetterSilverandClaude Opus 5.5 7efeed6c9e Add a demo-session bounce benchmark
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>
2026-10-27 16:52:30 +01:00
Jeremy 7511548c7e fix(installer): resolve the wizard scope and folder correctly
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.
2026-10-27 15:32:36 +00:00
aiongg 1e6310f85b fix(ui): dropdown menus grow back after a search with no matches (#1140)
* 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)
2026-10-27 10:27:11 -04:00
Jeremy 240726bf32 docs(installer): note that the 32-bit all-users folder is Program Files (x86); tidy wording 2026-10-27 12:25:20 +00:00
Jeremy 6686118627 docs(installer): describe the per-user default install location and scope 2026-10-27 12:09:03 +00:00
Jeremy 5396ecbdbc fix(installer): harden install-policy checks and packaging-lint workflow 2026-10-27 12:01:43 +00:00
Jeremy de6108a34d fix(installer): address scope and ARM64 review feedback 2026-10-27 12:01:43 +00:00
Jeremy 450ba857c1 feat(installer): default to a per-user ArtCraft folder 2026-10-27 12:01:42 +00:00
aiongg 60087e0f46 Merge pull request #1120 from aiongg/layout
refactor: shared module layout (craft-contrib#20)
2026-10-26 21:31:49 -04:00
skylar taylor-barrick 6dcac57282 Merge pull request #1121 from storytold/fix/nextest-0.9.150
ci: require nextest 0.9.150
2026-10-26 17:09:44 -06:00
aiongg aabe5ae320 test(engine): cut the file_access scan only at inline test modules 2026-10-26 22:05:23 +00:00
aiongg 8f50b5e455 docs: update source paths for the io, mix and panels moves 2026-10-26 20:35:50 +00:00
aiongg a69481c284 refactor(vst3-host): split ffi.rs into ffi/ 2026-10-26 20:35:05 +00:00
aiongg 49463b3c95 refactor(clap-host): split ffi.rs into ffi/ 2026-10-26 20:35:05 +00:00
aiongg 640180997c refactor(engine): split io.rs into io/ 2026-10-26 20:35:05 +00:00
aiongg 57f6033a6b refactor(ui-egui): split app.rs into app/ 2026-10-26 20:35:05 +00:00
aiongg 3c27179667 refactor(ui-egui): move the SoundApp methods to app.rs and the state and service types to state.rs and services.rs 2026-10-26 20:35:05 +00:00
aiongg fe945d8058 refactor(ui-egui): split mix_window.rs into mix_window/ 2026-10-26 20:35:05 +00:00
aiongg f196310402 refactor(ui-egui): split dialogs.rs into dialogs/ 2026-10-26 20:35:05 +00:00
aiongg 26b3e417a2 refactor(ui-egui): split panels.rs into panels/ 2026-10-26 20:35:05 +00:00
aiongg 2471f6b8ab refactor(ui-egui): split edit_window.rs into edit_window/ 2026-10-26 20:35:05 +00:00
aiongg 834fe6588f refactor(ui-egui): move recent.rs to dialogs/recent.rs 2026-10-26 20:35:05 +00:00
aiongg 0efc9a6e5e refactor(ui-egui): move recovery.rs to dialogs/recovery.rs 2026-10-26 20:35:05 +00:00
aiongg c28bf90d58 refactor(ui-egui): move ops_windows.rs to dialogs/ops_windows.rs 2026-10-26 20:35:05 +00:00
aiongg e90a6b4a7c refactor(ui-egui): move spot.rs to dialogs/spot.rs 2026-10-26 20:35:05 +00:00
aiongg e6eaf52e8c refactor(ui-egui): move video_window.rs to panels/video_window.rs 2026-10-26 20:35:05 +00:00
aiongg 9c7ff62bf5 refactor(ui-egui): move sampler_window.rs to panels/sampler_window.rs 2026-10-26 20:35:05 +00:00
aiongg 47c7420e4f refactor(ui-egui): move score_editor.rs to panels/score_editor.rs 2026-10-26 20:35:05 +00:00
aiongg 691dc895e4 refactor(ui-egui): move midi_editor.rs to panels/midi_editor.rs 2026-10-26 20:35:05 +00:00
aiongg db163e35b5 refactor(ui-egui): move extra_windows.rs to panels/extra_windows.rs 2026-10-26 20:35:05 +00:00
aiongg 120acefa9e refactor(ui-egui): move windows.rs to panels/windows.rs 2026-10-26 20:35:05 +00:00
aiongg efa2434f5c refactor(playback): move the audio callback state and stream opening out of lib.rs 2026-10-26 20:35:05 +00:00
aiongg 1ae4f569d2 refactor(mix): move the click module to click.rs 2026-10-26 20:27:42 +00:00
aiongg 3729da0776 refactor(mix): move strip processing, panning, routing and offline renders out of lib.rs 2026-10-26 20:27:42 +00:00
skylar 37dbadfc04 ci: require nextest 0.9.150
Use the fixed release for GHSA-vc25-qg58-w57c.
2026-10-26 14:13:06 -06:00
aiongg 19b3f5ec9f refactor(clap-host): move the non-Windows hostwin module to ffi/hostwin/other.rs 2026-10-26 18:16:35 +00:00
aiongg db6e7ed1cc refactor(clap-host): move the Windows hostwin module to ffi/hostwin/windows.rs 2026-10-26 18:16:35 +00:00
aiongg a53385acf0 refactor(vst3-host): move the Linux window module to ffi/window/other.rs 2026-10-26 18:16:35 +00:00
aiongg 408e5cd961 refactor(vst3-host): move the Windows window module to ffi/window/windows.rs 2026-10-26 18:16:34 +00:00
aiongg 741a381ae5 refactor(vst3-host): move the macOS window module to ffi/window/macos.rs 2026-10-26 18:16:33 +00:00
aiongg 20f4cca8ec refactor(soundcraft): move logging.rs tests to logging/tests.rs 2026-10-26 18:15:31 +00:00
aiongg 7c3547ffd4 refactor(engine): move edit.rs tests to edit/tests.rs 2026-10-26 18:15:31 +00:00
aiongg 23394f0b6a refactor(engine): move mix.rs tests to cmd/mix/tests.rs 2026-10-26 18:15:31 +00:00
aiongg 01c70688bf refactor(engine): move file.rs tests to cmd/file/tests.rs 2026-10-26 18:15:30 +00:00
aiongg c4fb9afb8b refactor(dsp): move sampler.rs tests to plugins/sampler/tests.rs 2026-10-26 18:15:30 +00:00
aiongg 552fdd5c1b refactor(playback): move lib.rs tests to tests.rs 2026-10-26 18:15:30 +00:00
aiongg fcd1e06d02 refactor(mix): move lib.rs tests to tests.rs 2026-10-26 18:15:29 +00:00
aiongg 97de0a35f2 refactor(ui-egui): move control.rs tests to control/tests.rs 2026-10-26 18:15:29 +00:00
aiongg 64154c7e71 refactor(ui-egui): move edit_window.rs tests to edit_window/tests.rs 2026-10-26 18:15:28 +00:00
aiongg 74f305d1e5 refactor(ui-egui): move lib.rs tests to tests.rs 2026-10-26 18:15:27 +00:00
aiongg e7103c3df5 refactor(dsp): move plugins/mod.rs to plugins.rs 2026-10-26 18:15:14 +00:00
aiongg 27a00c9f55 refactor(video): move prores/mod.rs to prores.rs 2026-10-26 18:15:14 +00:00
aiongg 9207a9d37c refactor(video): move h264/mod.rs to h264.rs 2026-10-26 18:15:14 +00:00
aiongg c1df0b83cd refactor(engine): move cmd/mod.rs to cmd.rs 2026-10-26 18:15:14 +00:00
aiongg 7dc56a4dbe ui: Tracks and Groups and Clips in full-height dock columns (#1088)
* 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
2026-10-25 21:33:15 -04:00
Dylan M. Taylor 088e270a5e refactor: split auto_pan process and audiosuite frame limit into helpers (#1104)
Reduces cognitive complexity flagged by SonarCloud (rust:S3776). Behaviour is unchanged:
per-sample order is envelope update, envelope trigger, LFO phase, modulator, width/manual/position
smoothing, output write.
2026-10-25 21:14:17 -04:00
aiongg 23f90a9164 chore(deps): safe dependency refresh (#1102) 2026-10-25 20:49:42 -04:00
Dylan M. Taylor 1c63b2a5b9 tests(clap-host): answer nextest's --list for the custom-harness editor test (#1100) 2026-10-25 20:14:33 -04:00
Dylan M. Taylor cf0f4d2f6a tests(vst3-host): answer nextest's --list for custom-harness tests (#1099) 2026-10-25 19:49:11 -04:00
Dylan M. Taylor e14cea42b2 Linux: default to XWayland for file drag-and-drop on Wayland desktops (#150)
* Linux: default to XWayland for file drag-and-drop on Wayland desktops

* Ignore an empty DISPLAY before forcing X11
2026-10-25 19:37:13 -04:00
Dylan M. Taylor d683aee3eb refactor: reduce cognitive complexity in audio-io, midi, dsp, engine, playback, plugin hosts (S3776) (#1097)
* refactor: reduce cognitive complexity in audio-io, midi, dsp, engine, playback, plugin hosts (S3776)

Extract helpers; behavior-preserving.

* fix: clippy lints in extracted helpers
2026-10-25 19:32:34 -04:00
Dylan M. Taylor 0197e27fc4 build: pin craft-update to craft-libs main (495e597) (#1096)
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
2026-10-25 17:50:34 -04:00
Dylan M. TaylorandCodebuff 5bbefeae62 build: pin craft-update to craft-libs main (495e597) (#1092)
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>
2026-10-25 17:37:38 -04:00
Dylan M. Taylor a44d1b830b Merge pull request #1014 from storytold/fix/sonar-s107
refactor: bundle parameters of long signatures (Sonar S107)
2026-10-25 16:35:58 -04:00
Dylan M. Taylor eec559f057 build: pin craft-update to craft-libs main (db19c37) (#1090)
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
2026-10-25 15:34:58 -04:00
Dylan M. Taylor b854771251 feat: startup update prompt modal and direct self-updating (#1073)
* 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.
2026-10-25 14:38:14 -04:00
aiongg 0a95b6dadf Merge pull request #1045 from forwardaudio/meters-accumulate
Meters: keep every block's peak until the UI takes it
2026-10-25 12:12:08 -04:00
aiongg cf96cb888c Follow-ups to #1058: Auto Pan keeps the stereo image, paste and Strip Silence fixes (#1060)
* 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
2026-10-25 12:06:36 -04:00
skylar taylor-barrick e0951aecf9 Migrate SoundCraft to egui 0.37 (#1063)
* fix(ui): preserve host keys during scripted input and clarify mixer labels

* Preserve host input while expiring scripted gestures

* Document migrated audio and UI behavior and regression invariants
2026-10-25 10:48:56 -04:00
Brandon ThomasandClaude Opus 5.5 01b39850ad Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:29:18 -04:00
Brandon ThomasandClaude Opus 5.5 b48df2a8b9 Release workflow: grant contents: write on the release job
#988 dropped the workflow-level permission for SonarCloud S8233 but never added the job-level one, so the 0.13.0 release run failed at startup: the shared workflow's draft-release job asks for contents: write.

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

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

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

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

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

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

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

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

* ui: test that activating a hidden panel shows it

* ui: name the field in ui.dock operation errors; document the shared panel menu and zones
2026-10-24 16:18:19 -04:00
Forward AudioandClaude Opus 5.5 37e155c828 Meters: keep every block's peak until the UI takes it
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>
2026-10-24 22:14:52 +02:00
dotCooCoo 0ca492e0ab Strip Silence: Extract keeps the silence, and Separate cuts the clips without removing any
The Strip Silence window had only a Strip button. It now also has
Extract and Separate. Extract removes the parts of the selection that
hold audio and keeps the silent parts. Separate separates the clips at
the same points Strip does and does not remove any clip. Enter on a
focused Extract or Separate runs that button, and Enter presses Strip
only when neither was clicked.

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

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

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

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

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

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

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

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

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

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

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

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

* style: apply rustfmt formatting

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* Dependabot: hold egui, eframe and egui_kittest at 0.36

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

* CI: check that git dependencies come from storytold

* CI: release only with git dependencies from storytold

* Parse Cargo.lock as TOML when checking git sources

* Use the shared git-sources check in CI

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

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

* Packaging: pin appimagetool and check its hash

* Reject AppImage download for architectures without a pinned appimagetool

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

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

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

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

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

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

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

* Playback: migrate the audio device layer to cpal 0.18

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

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

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

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

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

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

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

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

Follow-up to storytold/cadcraft#1785

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:04:41 +02:00
Dylan M. Taylor 172dc4edc6 docs: add SonarCloud and CodeRabbit badges to README 2026-10-18 11:01:17 -04:00
Dylan M. Taylor 31f0389397 docs: drop references to competing commercial software (#743) 2026-10-18 10:39:50 -04:00
Brandon ThomasandClaude Opus 5.5 c1bf044a85 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:43:22 -04:00
Brandon ThomasandClaude Opus 5.5 2578ea69a3 Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:16:15 -04:00
skylar taylor-barrick 3a1167ffd5 Merge pull request #726 from skylartaylor/linked-path-checks
Follow stored paths only on this computer or the session's share
2026-10-17 12:13:57 -06:00
skylar taylor-barrick 52bd33bb06 Build: take craft-docpath from the merged craft-libs rev
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.
2026-10-17 12:04:05 -06:00
reoniazx 516ef23703 fix(playback): fold both stereo channels into mono output 2026-10-18 00:42:19 +08:00
NetterSilverandClaude Haiku 5.5 6f3591ae11 Keep the existing volume lane in automation.clip_gain_to_volume
Found while testing crates/engine/src/cmd/mix.rs:676-681 (clip_gain_to_vol)

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

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

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

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

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

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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:11:34 +00:00
aiongg bde132e0bf ProRes: check frame dimensions before allocating planes
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.
2026-10-16 17:34:40 +00:00
aiongg 86b31e71d3 Sessions: follow stored source paths only on this computer or the session's share
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.
2026-10-16 17:32:35 +00:00
NetterSilverandClaude Haiku 5.5 4b1c025427 Honour start or end given alone in markers.add
Found while testing crates/engine/src/cmd/event.rs:300-305 (add_marker)

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

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

Closes #661
Related #639

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add public PCM regressions for deep chains, routing edits, callback sizes
and cycle compatibility, plus focused private ordering cases.
2026-10-14 13:14:38 -04:00
midasmatterhorn 76696826cd mix: clamp negative fractional source position in stretched-clip rendering 2026-10-14 18:01:42 +03:00
Brandon ThomasandClaude Opus 5.5 3a28a65769 Release 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:33:59 -04:00
Brandon ThomasandClaude Opus 5.5 01fd2d0cb4 Narrow Mix strips always use the short section names
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>
2026-10-14 09:15:34 -04:00
Hamza f8b2f8ce2e cli: reject unknown options and missing option values
`convert`, `run` and `script` read options with `arg_value`, which matches a
flag exactly and takes the argument after it. A misspelling, an `--option=value`
form or an option with no value therefore read as absent: the command ran
without what was asked for and still exited 0.

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

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

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

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

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

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

Closes #460

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:44:34 +05:30
webdevtodayjason e10b245652 Keep mute changes in place when thinning automation 2026-10-13 06:58:16 -05:00
Brandon Thomas c0cb993408 Merge pull request #413 from mikkelkonyher/checkbox-drag
Drag across checkboxes to check or uncheck several (#399)
2026-10-13 06:54:02 -04:00
Brandon Thomas cf755b8cd2 Merge pull request #410 from Silvertree2010/fix/instrument-params-render
Engine: mix.instrument takes the instrument's parameters and refuses unknown fields (#265)
2026-10-13 06:53:58 -04:00
Brandon Thomas 9ad9eb88e4 Merge pull request #408 from Silvertree2010/fix/bus-bounce-aux-latency
Mix: a bus bounce compensates the aux returns onto it (#246)
2026-10-13 06:53:54 -04:00
Brandon Thomas fca728501f Merge pull request #409 from Silvertree2010/fix/master-insert-latency
Mix: delay compensation covers the master faders' inserts (#351)
2026-10-13 06:53:50 -04:00
Brandon Thomas 55eff096ac Merge pull request #417 from Momentum96/fix/hangul-ui-fallback
Fall back to a system Hangul font so Korean names render in the UI
2026-10-13 06:53:46 -04:00
Brandon Thomas c2788c50d4 Merge pull request #415 from grovebit/tab-to-transient
Tab to Transient: every selected track, Alt+Tab back, Shift+Tab extends
2026-10-13 06:53:42 -04:00
Brandon Thomas fe573bb31f Merge pull request #416 from grovebit/transient-detection
Transient detection: find quiet and legato onsets, place them on the attack
2026-10-13 06:53:38 -04:00
Brandon Thomas 87db8f4048 Merge pull request #414 from grovebit/midi-double-click
Double-click a MIDI clip with the Grabber to open it in the MIDI Editor
2026-10-13 06:53:33 -04:00
Brandon Thomas ddb874e399 Merge pull request #407 from vbasky/fix/midi-editor-tool-insertion
Require the Pencil tool to insert MIDI notes
2026-10-13 06:53:29 -04:00
Brandon ThomasandClaude Opus 5.5 f4ed9919e6 Merge main into #413
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:46:08 -04:00
Brandon ThomasandClaude Opus 5.5 f8370f90f5 Resolve the plugin-parity header and revision rows after merging main into #410
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:45:43 -04:00
Brandon ThomasandClaude Opus 5.5 b32d7bd926 Merge main into #410; put the mix.instrument note in the Instruments row's 'have' cell
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:45:33 -04:00
Brandon ThomasandClaude Opus 5.5 14862a5139 Merge main into #408
render_bus moved into RangeRender (#393); the bus-latency fix now applies there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:45:20 -04:00
Brandon Thomas 6d4f9a41a5 Merge main into #409
# Conflicts:
#	ROADMAP.md
#	docs/architecture.md
2026-10-13 06:44:55 -04:00
Brandon Thomas a96cf50a3a Merge pull request #380 from grovebit/controls-look
Draw faders and meters more like Pro Tools
2026-10-13 06:38:28 -04:00
Momentum96 49abc39806 Fall back to a system Hangul font so Korean names render in the UI
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.
2026-10-13 19:34:21 +09:00
Brandon Thomas 8e360b336b Merge pull request #319 from Limestudio90/audio-device-settings
Audio device settings (system, devices, rate, buffer) and opt-in ASIO
2026-10-13 06:32:41 -04:00
Brandon Thomas afa850d697 Merge main into #319
# Conflicts:
#	ROADMAP.md
2026-10-13 06:32:14 -04:00
Brandon Thomas 7c9a18878b Merge pull request #322 from Limestudio90/mix-window-separate
Mix in a separate window: Edit and Mix on screen at once
2026-10-13 06:32:11 -04:00
Brandon Thomas 844830bf6f Merge main into #322
# Conflicts:
#	ROADMAP.md
2026-10-13 06:31:36 -04:00
Brandon Thomas acbc3411e5 Merge pull request #334 from scottcampbelldata/sampler-ui
Sampler UI: load samples, waveform with loop points, instrument sliders
2026-10-13 06:31:27 -04:00
Brandon Thomas 13316cf60e Merge pull request #315 from Limestudio90/plugin-editors-windows
Plugin editor windows on Windows (VST3 and embedded CLAP)
2026-10-13 06:31:24 -04:00
Brandon Thomas 9176296e96 Merge pull request #390 from woodgrain816/open-recent
File › Open Recent: the last 10 sessions, newest first
2026-10-13 06:31:20 -04:00
Brandon Thomas 18dcb7b870 Merge pull request #219 from Jafz2001/feat/routing-folders
Complete routing folder management
2026-10-13 06:31:16 -04:00
Brandon Thomas 58a0482712 Merge pull request #335 from reoniazx/fix/fade-drag-waveform-preview
Fix clip fade editing and add live waveform previews
2026-10-13 06:31:12 -04:00
Brandon Thomas 751a5755cb Merge pull request #338 from grovebit/menu-search
Type to search as soon as a plugin or bus menu opens
2026-10-13 06:31:05 -04:00
Brandon Thomas 430c6a9e4e Merge pull request #332 from grovebit/corrupt-sessions
Keep corrupt sessions and imports from hanging, crashing or filling memory
2026-10-13 06:31:01 -04:00
Brandon Thomas cc1bf5a62d Merge pull request #296 from grovebit/new-midi-clip
Create MIDI clips with the Pencil, Consolidate and the MIDI Editor
2026-10-13 06:30:57 -04:00
Brandon Thomas 123ae65e2b Merge pull request #306 from grovebit/plugin-safety
Plugin safety: read plugin state off the audio thread, keep refused settings, contain NaN plugins
2026-10-13 06:30:53 -04:00
Brandon Thomas 5b824e46c8 Merge pull request #209 from grovebit/zoom-toggle
Zoom Toggle: E zooms in on the edit selection and back out, as in Pro Tools
2026-10-13 06:30:49 -04:00
Brandon Thomas d438c753e1 Merge pull request #360 from grovebit/session-copies
Share the session with the UI instead of copying it every frame
2026-10-13 06:30:45 -04:00
Brandon Thomas 69aea8fad3 Merge pull request #384 from grovebit/track-height-drag
Track height: resize live while dragging, Pro Tools modifiers and Ctrl+Up/Down
2026-10-13 06:30:40 -04:00
Brandon Thomas 768629a8ce Merge pull request #365 from grovebit/edit-modes
Make the Shuffle, Slip, Spot and Grid edit modes work like Pro Tools
2026-10-13 06:30:33 -04:00
Brandon Thomas a8594684a1 Merge pull request #393 from grovebit/bounce-streaming
Stream bounces to disk and keep the app responsive while bouncing
2026-10-13 06:30:29 -04:00
Brandon Thomas bbafeb85f6 Merge pull request #367 from grovebit/plugin-transport-midi
Give plugins the session's tempo and transport, and play MIDI controllers
2026-10-13 06:30:26 -04:00
Brandon Thomas 695811ad13 Merge pull request #369 from grovebit/live-take-waveform
Draw each take in the Edit window while it records
2026-10-13 06:30:22 -04:00
Brandon Thomas 82a83ae6f4 Merge pull request #215 from SteamSmith/fix/control-line-limits
control server: bound request lines and concurrent connections
2026-10-13 06:30:18 -04:00
Brandon Thomas 376e9f03ff Merge pull request #328 from grovebit/generator-render
Signal Generator fills the selection with a new clip
2026-10-13 06:30:13 -04:00
Brandon Thomas 2a675b7ae4 Merge pull request #300 from grovebit/bounce-browse
Bounce Mix: Browse for the file, and start in the session folder
2026-10-13 06:30:09 -04:00
Brandon Thomas 65829dbb1c Merge pull request #368 from grovebit/recording-safety
Keep takes when the input, output or disk fails, or the app crashes
2026-10-13 06:30:06 -04:00
Brandon ThomasandClaude Opus 5.5 e65a66c539 Keep one build_input after merging #319's latest (the guarded one with device-loss detection)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:27:17 -04:00
Brandon ThomasandClaude Opus 5.5 baea184bd1 Keep one VST3 controller-sync path after merging #306's latest
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>
2026-10-13 06:26:54 -04:00
Brandon Thomas 1dcd6aa064 Merge #322's latest into the landing branch 2026-10-13 06:25:57 -04:00
Brandon Thomas 94390f49fa Merge #319's latest into the landing branch 2026-10-13 06:25:57 -04:00
Brandon Thomas 20927d7684 Merge #315's latest into the landing branch 2026-10-13 06:25:56 -04:00
Brandon Thomas 35ba903358 Merge #338's latest into the landing branch 2026-10-13 06:25:56 -04:00
Brandon Thomas 8b78e0306b Merge #332's latest into the landing branch 2026-10-13 06:25:56 -04:00
Brandon Thomas f29b4ef325 Merge #296's latest into the landing branch 2026-10-13 06:25:56 -04:00
Brandon Thomas bd5b5f17fa Merge #209's latest into the landing branch 2026-10-13 06:25:56 -04:00
Brandon Thomas 03815fbf4d Merge #360's latest into the landing branch 2026-10-13 06:25:56 -04:00
Brandon Thomas 19b1f33eb5 Merge #384's latest into the landing branch 2026-10-13 06:25:56 -04:00
Brandon Thomas 9b362a7244 Merge #365's latest into the landing branch 2026-10-13 06:25:56 -04:00
Brandon Thomas 0dc7b1ebec Merge #393's latest into the landing branch 2026-10-13 06:25:56 -04:00
Brandon Thomas 0c30ecffcc Merge #367's latest into the landing branch 2026-10-13 06:25:56 -04:00
Brandon Thomas 7a6df4cbbd Merge #306's latest into the landing branch 2026-10-13 06:25:56 -04:00
Brandon Thomas d434cf6b15 Merge #369's latest into the landing branch 2026-10-13 06:25:55 -04:00
Brandon Thomas c2f052715d Merge main into #319 2026-10-13 06:16:54 -04:00
Brandon Thomas edd1787353 Merge main into #322 2026-10-13 06:13:17 -04:00
Brandon Thomas ec8d1ed6a8 Merge #322 2026-10-13 06:13:17 -04:00
Brandon ThomasandClaude Opus 5.5 bec0cca48d Fix build after merging main into #334
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:12:51 -04:00
Brandon Thomas b2e4dee0dd Merge #334 2026-10-13 06:12:51 -04:00
Brandon Thomas 00faedcbd0 Merge main into #334 2026-10-13 06:09:17 -04:00
Brandon Thomas b0daf3213e Merge main into #315 2026-10-13 06:08:18 -04:00
Brandon Thomas 5323c8ef25 Merge #315 2026-10-13 06:08:18 -04:00
grovebit 67c51ef198 Merge origin/main into corrupt-sessions
#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.
2026-10-13 11:08:01 +01:00
grovebit 2895fa7dd0 Merge origin/main into plugin-transport-midi
#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.
2026-10-13 11:06:09 +01:00
Brandon Thomas 5686d61f45 Merge main into #390 2026-10-13 06:04:00 -04:00
Brandon Thomas bee60a375c Merge #390 2026-10-13 06:04:00 -04:00
grovebit 221661a46a Merge origin/main into edit-modes
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`.
2026-10-13 11:03:29 +01:00
grovebit 7ec362962c Merge origin/main into session-copies
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.
2026-10-13 11:03:01 +01:00
grovebit dd57e2a9e6 Merge origin/main into bounce-streaming
#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.
2026-10-13 11:01:51 +01:00
Brandon Thomas 82eff0f561 Merge main into #219 2026-10-13 06:01:05 -04:00
Brandon Thomas a16bb01915 Merge #219 2026-10-13 06:01:05 -04:00
Brandon ThomasandClaude Opus 5.5 6409a8cbbd Fix build after merging main into #335
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:00:30 -04:00
Brandon Thomas 6583ec6032 Merge #335 2026-10-13 06:00:30 -04:00
grovebit 776a7af06c Merge origin/main into plugin-safety
#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.
2026-10-13 10:58:07 +01:00
grovebit 46bef836a3 Merge remote-tracking branch 'origin/main' into controls-look
# Conflicts:
#	crates/ui-egui/src/mix_window.rs
#	crates/ui-egui/src/widgets.rs
2026-10-13 10:56:01 +01:00
grovebit 1d562bb009 Merge remote-tracking branch 'origin/main' into menu-search
# Conflicts:
#	crates/ui-egui/src/edit_window.rs
#	crates/ui-egui/src/mix_window.rs
2026-10-13 10:56:01 +01:00
grovebit 2094c7fbd2 Merge remote-tracking branch 'origin/main' into track-height-drag
# Conflicts:
#	crates/engine/src/cmd/track.rs
#	crates/ui-egui/src/edit_window.rs
#	crates/ui-egui/src/shortcuts.rs
2026-10-13 10:56:01 +01:00
Brandon Thomas e0deb3f4e5 Merge main into #335 2026-10-13 05:55:06 -04:00
Brandon Thomas bae80849dd Merge main into #338 2026-10-13 05:51:51 -04:00
Brandon Thomas 4fcc387571 Merge #338 2026-10-13 05:51:51 -04:00
Mikkel konyher bca2b4af07 Drag across checkboxes to check or uncheck several (#399)
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).
2026-10-13 11:51:48 +02:00
grovebit 41f6670889 Merge remote-tracking branch 'origin/main' into new-midi-clip
# Conflicts:
#	crates/engine/src/cmd/edit.rs
2026-10-13 10:51:30 +01:00
grovebit 338411b99a Merge remote-tracking branch 'origin/main' into zoom-toggle
# Conflicts:
#	crates/model/src/lib.rs
2026-10-13 10:51:29 +01:00
grovebit fd1348f1b3 Merge remote-tracking branch 'origin/main' into live-take-waveform
# Conflicts:
#	crates/ui-egui/src/edit_window.rs
2026-10-13 10:51:29 +01:00
Limestudio90andClaude Opus 5.5 3b7a1f2b33 ROADMAP: no blank line inside the progress log (rebase leftover)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:47:49 +02:00
Limestudio90andClaude Opus 5.5 6d910609e1 Docs: Mix in a separate window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:47:42 +02:00
Limestudio90andClaude Opus 5.5 ea531b723f Mix in a separate window: Edit and Mix on screen at once
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>
2026-10-13 11:47:42 +02:00
Limestudio90andClaude Opus 5.5 0e6f5accb4 ROADMAP: no blank line inside the progress log (rebase leftover)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:47:30 +02:00
Limestudio90andClaude Opus 5.5 d8b2961651 Close plugin editors before reopening the audio device
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>
2026-10-13 11:46:55 +02:00
Limestudio90andClaude Opus 5.5 0a2492b610 Audio device settings: system, devices, sample rate, buffer size; opt-in ASIO
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>
2026-10-13 11:46:55 +02:00
Limestudio90andClaude Opus 5.5 fbee545a5e ROADMAP: no blank line inside the progress log (rebase leftover)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:44:51 +02:00
Limestudio90andClaude Opus 5.5 7eb643fc5e clap-host: host embedded plugin editors on Windows
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>
2026-10-13 11:44:15 +02:00
Limestudio90andClaude Opus 5.5 927eeb58cf vst3-host: host plugin editors on Windows
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>
2026-10-13 11:44:07 +02:00
Brandon Thomas 857882c740 Merge main into #332 2026-10-13 05:40:16 -04:00
Brandon Thomas 92ed201515 Merge #332 2026-10-13 05:40:16 -04:00
Brandon Thomas 67f486d4c4 Merge main into #296 2026-10-13 05:34:50 -04:00
Brandon Thomas 7fb734dab8 Merge #296 2026-10-13 05:34:50 -04:00
Brandon ThomasandClaude Opus 5.5 ca1a6d443e Fix build after merging main into #306
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:31:48 -04:00
Brandon Thomas cbfaff70e7 Merge #306 2026-10-13 05:31:48 -04:00
Brandon Thomas 056bc288c4 Merge main into #306 2026-10-13 05:31:14 -04:00
Brandon Thomas 468125aaf4 Merge main into #209 2026-10-13 05:27:22 -04:00
Brandon Thomas 262392cd23 Merge #209 2026-10-13 05:27:22 -04:00
Brandon ThomasandClaude Opus 5.5 e927e295bb Fix build after merging main into #360
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:24:58 -04:00
Brandon Thomas 4ebdc35b04 Merge #360 2026-10-13 05:24:58 -04:00
Brandon Thomas 70c9bcf7dd Merge main into #360 2026-10-13 05:24:45 -04:00
Brandon Thomas 3f3f64746f Merge main into #384 2026-10-13 05:21:33 -04:00
Brandon Thomas dec75a8086 Merge #384 2026-10-13 05:21:33 -04:00
Brandon ThomasandClaude Opus 5.5 bb3a6bdd9a Fix build after merging main into #365
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:17:58 -04:00
Brandon Thomas cd981d723e Merge #365 2026-10-13 05:17:58 -04:00
Brandon Thomas 2bba5c334d Merge main into #365 2026-10-13 05:17:58 -04:00
NetterSilver dd3ef4b11a Docs: update the progress docs for this change 2026-10-13 11:17:57 +02:00
NetterSilver 1c2128ec37 Engine: mix.instrument takes the instrument's parameters and refuses unknown fields (#265)
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.
2026-10-13 11:17:57 +02:00
Brandon Thomas 5051b4a45f Merge main into #393 2026-10-13 05:12:31 -04:00
Brandon Thomas edcdf770b2 Merge #393 2026-10-13 05:12:31 -04:00
Brandon Thomas e32c16b82e Merge main into #367 2026-10-13 05:11:18 -04:00
Brandon Thomas 9c994f3f7b Merge #367 2026-10-13 05:11:18 -04:00
NetterSilver c4573d7fc8 Docs: update the progress docs for this change 2026-10-13 11:11:12 +02:00
NetterSilver 446a059faf Mix: delay compensation covers the master faders' inserts (#351)
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.
2026-10-13 11:11:12 +02:00
Brandon Thomas c46d6efd4c Merge #369 2026-10-13 05:08:27 -04:00
Brandon Thomas a47135b228 Merge main into #369 2026-10-13 05:08:26 -04:00
Brandon Thomas 5d98ddfa69 Merge main into #215 2026-10-13 05:03:15 -04:00
Brandon Thomas 574b3117e7 Merge #215 2026-10-13 05:03:15 -04:00
NetterSilver 3498db9e51 Docs: update the progress docs for this change 2026-10-13 11:01:34 +02:00
NetterSilver 5cd155bb64 Mix: a bus bounce compensates the aux returns onto it (#246)
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.
2026-10-13 11:01:34 +02:00
Vikram Bhaskaran 456cb859b3 Restrict MIDI note insertion to the Pencil tool to prevent accidental edits.
Signed-off-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
2026-10-13 20:00:52 +11:00
Brandon Thomas b4f7c4af6d Merge #328 2026-10-13 04:59:31 -04:00
Brandon Thomas 0eee82a49c Merge main into #328 2026-10-13 04:59:30 -04:00
Brandon Thomas d4b7300a1e Merge main into #300 2026-10-13 04:56:48 -04:00
Brandon Thomas ae7155d95f Merge #300 2026-10-13 04:56:48 -04:00
Brandon Thomas 93181762ee Merge #368 2026-10-13 04:56:40 -04:00
Brandon ThomasandClaude Opus 5.5 3cc0dc77cb Fix build after merging main into #368
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:56:26 -04:00
Brandon Thomas 46399aa729 Merge main into #368 2026-10-13 04:50:04 -04:00
Brandon Thomas 8d5b38ab3a Merge pull request #406 from storytold/fix/reachability-ratchet
Fix main: drop now-reachable commands from the reachability ratchet
2026-10-13 04:49:40 -04:00
Brandon ThomasandClaude Opus 5.5 391a0aa66c Drop track.timebase and track.elastic from the unreachable-commands list
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>
2026-10-13 04:49:31 -04:00
Brandon ThomasandClaude Opus 5.5 3d1a30b61d Audio device: keep the new choice only once the devices reopen with it
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>
2026-10-13 04:43:55 -04:00
Brandon Thomas 5495378d7a Merge pull request #309 from isaac-levine/mcp-automation-roots
MCP: confine file access with --automation-read-root and --automation-write-root
2026-10-13 04:43:44 -04:00
Brandon Thomas 9c7e5018b3 Merge pull request #333 from grovebit/strip-silence-long
Stop Strip Silence deleting clips late in long selections
2026-10-13 04:43:41 -04:00
Brandon Thomas 849d1a6b07 Merge pull request #331 from grovebit/corrupt-audio
Refuse corrupt audio files instead of crashing
2026-10-13 04:43:37 -04:00
Brandon Thomas 663ccd0004 Merge pull request #330 from grovebit/new-track-button
Add tracks from the Tracks list and the empty space below the tracks
2026-10-13 04:43:34 -04:00
Brandon Thomas 061c238e68 Merge pull request #329 from grovebit/record-unarmed
Say at the Record button why Record did nothing
2026-10-13 04:43:30 -04:00
Brandon Thomas 50b0f68363 Merge pull request #320 from grovebit/range-shortcuts
Make the Zoom Preset, Waveform Zoom and Clip Rating keys work; add Commands Focus 1–5 and N
2026-10-13 04:43:26 -04:00
Brandon Thomas 6c836b4389 Merge pull request #308 from grovebit/automation-overwrite
Automation passes replace the automation they play over, in one undo step
2026-10-13 04:43:21 -04:00
Brandon Thomas ed42ac49f9 Merge pull request #299 from grovebit/marker-rename
Rename a marker by double-clicking it in the ruler
2026-10-13 04:43:16 -04:00
Brandon Thomas 1831b4173a Merge pull request #298 from grovebit/tempo-drag
Drag or type the tempo in the toolbar
2026-10-13 04:43:12 -04:00
Brandon Thomas 116ccfac5b Merge pull request #297 from grovebit/track-list-reorder
Reorder tracks by dragging them in the Tracks list
2026-10-13 04:43:05 -04:00
Brandon Thomas 0ef67b795b Merge pull request #210 from grovebit/realtime-safety
Stop allocating and freeing memory on the audio thread
2026-10-13 04:43:00 -04:00
Brandon Thomas b764e299bc Merge pull request #326 from grovebit/long-renders
Refuse renders too long to finish instead of cutting the audio short
2026-10-13 04:42:56 -04:00
Brandon Thomas 3d5b4dec8d Merge pull request #389 from grovebit/tab-focus
Keep shortcuts working after Tab
2026-10-13 04:42:51 -04:00
Brandon Thomas b81bd5e064 Merge pull request #361 from grovebit/track-name-menu
Right-click a track's name for the track menu, in Edit and Mix
2026-10-13 04:42:46 -04:00
Brandon Thomas 007e6ab78f Merge pull request #359 from grovebit/grid-over-clips
Draw grid lines over clips in the Edit window
2026-10-13 04:42:42 -04:00
Brandon Thomas f08581b545 Merge pull request #378 from grovebit/tracks-scroll-bar
Mark the Tracks panel's edge with a scroll bar
2026-10-13 04:42:38 -04:00
Brandon Thomas e5ca24cc3e Merge pull request #397 from grovebit/mix-narrow-headers
Fit the section names in narrow Mix strips
2026-10-13 04:42:33 -04:00
Brandon Thomas 7676de2749 Merge pull request #383 from grovebit/pt-track-headers
Lay out Edit window tracks and plugin slots like Pro Tools
2026-10-13 04:42:30 -04:00
Brandon Thomas 7347e98e79 Merge pull request #363 from grovebit/more-shortcuts
Pro Tools keys for nudging, Grid and Nudge values, playlists and windows
2026-10-13 04:42:25 -04:00
Brandon ThomasandClaude Opus 5.5 33a6c3dfbc Test Mix in a separate window: the toggle and closing it
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>
2026-10-13 04:38:59 -04:00
Brandon ThomasandClaude Opus 5.5 74b3278a19 control server: cap request lines at about 86 MiB, not 256 MiB
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>
2026-10-13 04:37:04 -04:00
Brandon ThomasandClaude Opus 5.5 41c8561d51 Signal Generator refuses a selection longer than one render
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>
2026-10-13 04:33:35 -04:00
Brandon Thomas c7a4c544c0 Merge pull request #261 from Silvertree2010/fix/cli-convert-validation
Reject invalid --rate and --bit-depth in convert (#256)
2026-10-13 04:33:02 -04:00
Brandon Thomas 16335b8259 Merge pull request #259 from Silvertree2010/fix/bar-start-tick-overflow
Saturate the bar offset for bars before 1 (#249)
2026-10-13 04:32:58 -04:00
Brandon Thomas bdff15defb Merge pull request #387 from itrycodinganditsbad/fix-250
Keep later meter changes on their bars when an earlier meter is edited
2026-10-13 04:32:53 -04:00
Brandon Thomas aa91e47073 Merge pull request #303 from marcoarnulfo/fix/restore-last-selection
Restore Last Selection brings back the previous selection
2026-10-13 04:32:49 -04:00
Brandon Thomas 3b7415db90 Merge pull request #291 from marcoarnulfo/fix/tempo-constant-range
fix(event): Constant tempo applies only over its range (#238)
2026-10-13 04:32:45 -04:00
Brandon ThomasandClaude Opus 5.5 f4460e1ec7 Merge refs/pr/326 (refuse over-long renders) into fix328
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:31:59 -04:00
Brandon ThomasandClaude Opus 5.5 285d9fecd5 Bounce Mix: a session path without a folder starts in the default one
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>
2026-10-13 04:31:47 -04:00
Brandon ThomasandClaude Opus 5.5 8222048c10 Pass the Feet+Frames start to parse_position in the bar-offset test (after #294)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:30:30 -04:00
Brandon Thomas 7c061de737 Merge main into #261
# Conflicts:
#	ROADMAP.md
2026-10-13 04:30:18 -04:00
Brandon Thomas f4233dc8f7 Merge main into #259
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
2026-10-13 04:30:18 -04:00
Brandon Thomas ba4c234344 Merge main into #387
# Conflicts:
#	crates/engine/src/cmd/event.rs
2026-10-13 04:30:17 -04:00
Brandon Thomas 0f6bd8a573 Merge main into #303
# Conflicts:
#	crates/engine/src/cmd/edit.rs
2026-10-13 04:30:17 -04:00
Brandon Thomas 52294bd4a3 Merge main into #291
# Conflicts:
#	crates/engine/src/cmd/event.rs
2026-10-13 04:30:17 -04:00
Brandon Thomas 12d9f4dde6 Merge pull request #260 from Silvertree2010/fix/timecode-overflow
Compute timecode frame counts in i128 and clamp (#248)
2026-10-13 04:29:45 -04:00
Brandon Thomas 6dfa00590b Merge pull request #304 from marcoarnulfo/fix/midi-track-offsets
Apply MIDI track offsets when scheduling notes
2026-10-13 04:29:40 -04:00
Brandon Thomas 6bab8365e6 Merge pull request #289 from marcoarnulfo/fix/clear-trim-automation
fix(track): Clear Trim Automation removes the trim lane (#233)
2026-10-13 04:29:36 -04:00
Brandon Thomas cd1ddff0c0 Merge pull request #324 from iannitram/fix/gate-on-windows-and-rust-1.95
Make cargo xtask ci pass on Windows, and on Rust 1.95
2026-10-13 04:29:32 -04:00
Brandon Thomas ec67064398 Merge pull request #355 from mroxso/fix/ui-test-palette-lock-323
fix(ui-egui): own the test palette lock for every SoundApp lifetime (#323)
2026-10-13 04:29:27 -04:00
Brandon Thomas e59f079a02 Merge pull request #342 from mroxso/t3code/76964692
fix(mix): mix.insert_params rejects unknown parameter names
2026-10-13 04:29:23 -04:00
Brandon Thomas b0339a418f Merge pull request #401 from Claudio911015/fix/273-clip-loop-unloop-consistency
Make clip.loop / clip.unloop self-consistent (loop_id tag)
2026-10-13 04:29:19 -04:00
Brandon Thomas d9b959ed6c Merge pull request #400 from marcoarnulfo/fix/vst3-set-param-controller-sync
VST3: hand host-set parameter values to the controller before saving state
2026-10-13 04:29:15 -04:00
Brandon Thomas 75e019dde3 Merge pull request #301 from marcoarnulfo/fix/seek-flushes-state
Locate clears plugin and delay-compensation state
2026-10-13 04:29:11 -04:00
Brandon Thomas e5ca13d07f Merge pull request #327 from Limestudio90/fix-replaced-instance
mix: a newly adopted plugin instance replaces a matching one in its slot
2026-10-13 04:29:07 -04:00
Brandon Thomas a307f767ba Merge pull request #212 from itrycodinganditsbad/consolidate-selection
Consolidate covers the whole edit selection (#164)
2026-10-13 04:28:45 -04:00
Brandon Thomas f868c2678f Merge pull request #386 from itrycodinganditsbad/fix-240
fix(meter): keep K-weighting stable below ~3.4 kHz sample rates
2026-10-13 04:28:41 -04:00
Brandon ThomasandClaude Opus 5.5 dac86131ee Recovery repairs only take files in the folders recordings write to
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>
2026-10-13 04:28:36 -04:00
Brandon Thomas 34117efc24 Merge pull request #354 from webdevtodayjason/automation-thin-tolerance
Keep automation thinning within its tolerance
2026-10-13 04:28:36 -04:00
Brandon Thomas 651b9049a8 Merge pull request #353 from webdevtodayjason/trim-keeps-clip-gain
Keep the clip gain line where a clip is trimmed or separated
2026-10-13 04:28:31 -04:00
Brandon Thomas a89c0c4db5 Merge pull request #379 from webdevtodayjason/trim-to-selection-order
Keep clips in order after Trim to Selection
2026-10-13 04:28:26 -04:00
Brandon Thomas 54254639cc Merge pull request #358 from sajibsrs/fix/282-new-midi-clip-menu
Add Clip › New MIDI Clip so an empty MIDI track can get a clip (#282)
2026-10-13 04:28:22 -04:00
Brandon Thomas fe8270e6dc Merge pull request #293 from MarekSpodBiedry/m4b-open
Open .m4b audiobook files like .m4a
2026-10-13 04:28:17 -04:00
Brandon Thomas 2d1f25b1bb Merge pull request #264 from TheMojoMan/toolbar-horizontal-scroll
fix(ui): horizontally scroll the Edit toolbar
2026-10-13 04:28:12 -04:00
Brandon Thomas 8eb1d1aa77 Merge pull request #313 from lunar-me/fix-docs/issue-008
docs: fix grammar issue in docs/target-app-parity.md
2026-10-13 04:28:07 -04:00
Brandon Thomas d85889aafa Merge pull request #312 from lunar-me/fix-docs/issue-007
docs: fix grammar issue in docs/target-app-parity.md
2026-10-13 04:28:03 -04:00
Brandon Thomas 63717328c5 Merge pull request #311 from lunar-me/fix-docs/issue-002
docs: fix grammar issue in README.md
2026-10-13 04:27:58 -04:00
Brandon Thomas 18579f546c Merge pull request #295 from marcoarnulfo/fix/pan-automation-bounce
fix(mix): pan automation reads the same in playback and bounce
2026-10-13 04:27:50 -04:00
Brandon Thomas 3c53d25413 Merge pull request #294 from marcoarnulfo/fix/feet-frames-reference
Apply the Feet+Frames reference to counters and typed positions
2026-10-13 04:27:47 -04:00
Brandon Thomas 3ef50746c8 Merge pull request #292 from marcoarnulfo/fix/trim-no-overlap
fix(edit): trims stop at the neighbouring clips instead of overlapping them (#228, #229)
2026-10-13 04:27:43 -04:00
Brandon Thomas 704ebc3c76 Merge pull request #288 from marcoarnulfo/fix/clip-gain-cut-clipboard
fix(edit): cut clip gain keeps the removed gain for paste (#230)
2026-10-13 04:27:37 -04:00
Brandon Thomas db9ea775fc Merge pull request #287 from marcoarnulfo/fix/midi-backspace-rename
fix(midi-editor): Backspace in a text field leaves the selected notes alone (#223)
2026-10-13 04:27:30 -04:00
Brandon Thomas 8c33cefe1c Merge pull request #286 from marcoarnulfo/fix/modulation-feedback-tail
fix(dsp): chorus and flanger tails follow their feedback (#242)
2026-10-13 04:27:23 -04:00
Brandon Thomas 5bb3e7b3b6 Merge pull request #285 from marcoarnulfo/fix/pitch-shift-full-range
fix(dsp): pitch_shift reaches ±48 semitones (#243)
2026-10-13 04:27:18 -04:00
Brandon Thomas f58da8fe8a Merge pull request #284 from marcoarnulfo/fix/elastic-properties-ratio
fix(clip): Elastic Properties stretches the clip by the requested ratio (#227)
2026-10-13 04:27:14 -04:00
Brandon Thomas 99dbd4d628 Merge pull request #283 from marcoarnulfo/fix/marker-edit-name
fix(markers): markers.edit applies the documented name (#236)
2026-10-13 04:27:10 -04:00
Brandon Thomas 741010d50c Merge pull request #281 from marcoarnulfo/fix/h264-param-set-overflow
fix(video): H.264 parameter sets check their arithmetic before range checks (#251, #252)
2026-10-13 04:27:06 -04:00
Brandon Thomas 3726c57d78 Merge pull request #280 from marcoarnulfo/fix/eq-discrete-redesign
fix(eq): switches and choices redesign the filter on a running EQ (#239)
2026-10-13 04:27:02 -04:00
Brandon Thomas e5787ab7ac Merge pull request #279 from marcoarnulfo/fix/midi-quantize-grid
fix(midi-editor): Quantize snaps to the grid the editor shows (#226)
2026-10-13 04:26:56 -04:00
Brandon Thomas f146bc359f Merge pull request #278 from marcoarnulfo/fix/pencil-plugin-range
fix(edit-window): Pencil clicks on plugin lanes use the plugin's range (#224)
2026-10-13 04:26:53 -04:00
Brandon Thomas 12224df8f5 Merge pull request #277 from marcoarnulfo/fix/hide-floating-automation
fix(window): Hide All Floating Windows also hides Automation (#253)
2026-10-13 04:26:49 -04:00
Brandon Thomas baf102e13f Merge pull request #276 from marcoarnulfo/fix/filmstrip-clip-start
fix(video-universe): filmstrip thumbnails use the clip's own movie time (#225)
2026-10-13 04:26:45 -04:00
Brandon Thomas bd7e8f271f Merge pull request #275 from marcoarnulfo/fix/video-selection-midi-target
fix(midi-editor): a selected video clip is not a MIDI target (#258)
2026-10-13 04:26:42 -04:00
Brandon Thomas b8e206273c Merge pull request #272 from marcoarnulfo/fix/midi-front-keeps-editor
fix(window): keep the MIDI Editor open on Bring to Front (#254)
2026-10-13 04:26:37 -04:00
Brandon Thomas 5c1f9d7dc8 Merge pull request #271 from marcoarnulfo/fix/command-filter-ui-rows
fix(control): apply the engine.commands filter to UI commands (#255)
2026-10-13 04:26:33 -04:00
Brandon Thomas d14edcd51c Merge pull request #270 from marcoarnulfo/fix/track-vca-null
fix(track): vca: null clears a VCA assignment, as documented (#237)
2026-10-13 04:26:28 -04:00
Brandon Thomas 06a4b21663 Merge pull request #269 from marcoarnulfo/fix/pitch-detect-large-rate
fix(dsp): a huge finite sample rate is no pitch, not an overflow (#241)
2026-10-13 04:25:54 -04:00
Brandon ThomasandClaude Opus 5.5 5183bc4dba rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:22:21 -04:00
Brandon ThomasandClaude Opus 5.5 ad8beac9b4 Look up the MIDI track offset without allocating on the audio thread
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>
2026-10-13 04:21:20 -04:00
Brandon ThomasandClaude Opus 5.5 165fa98a90 Count tracks, not lanes, in track.clear_trim_automation's result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:21:20 -04:00
Claudio bee999584f Make clip.unloop recognise loops made by clip.loop, even after edits
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.
2026-10-12 23:54:33 -06:00
Marco Arnulfo 40c5e9b42a VST3: hand host-set parameter values to the controller before saving state (fixes #257)
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.
2026-10-13 07:06:22 +02:00
grovebit f188b66209 Leave controller events before a clip's start silent
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.
2026-10-13 02:42:15 +01:00
grovebit eada1c4cfe Fit the section names in narrow Mix strips
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.
2026-10-13 01:36:13 +01:00
grovebit c82b3887f0 Draw faders and meters more like Pro Tools
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.
2026-10-13 01:33:28 +01:00
grovebit 7be845abd1 Transient detection: find quiet and legato onsets, place them on the attack
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.
2026-10-13 00:46:34 +01:00
grovebit b9e4873ffa Tab to Transient: every selected track, Alt+Tab back, Shift+Tab extends
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.
2026-10-12 23:51:18 +01:00
grovebit 8f8884ec05 Refuse to replace the session while bouncing
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.
2026-10-12 23:47:24 +01:00
grovebit d19d44cd1b Cap bogus plugin latency so offline renders can't stall
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.
2026-10-12 23:47:24 +01:00
grovebit 52e19a871a Keep the app responsive while bouncing
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.
2026-10-12 23:47:00 +01:00
grovebit 5755795663 Stream bounces to disk instead of rendering them in memory
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.
2026-10-12 23:46:13 +01:00
grovebit 7f9b629964 Draw each take in the Edit window while it records
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).
2026-10-12 23:43:58 +01:00
grovebit 1cd0233f49 Open a MIDI clip in the MIDI Editor by double-clicking it with the Grabber
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.
2026-10-12 23:42:58 +01:00
grovebit e44c49cada Record NaN and infinite input samples as silence
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.
2026-10-12 23:33:48 +01:00
grovebit 30122bcea1 Recover the takes of a recording a crash interrupted
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.
2026-10-12 23:33:48 +01:00
grovebit 62cb92c4df Never replace or quit a session while it records
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.
2026-10-12 23:33:48 +01:00
grovebit 0b9ead430f Stop the transport when the audio output device goes away
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.
2026-10-12 23:33:48 +01:00
grovebit ddb9c9543d Stop recording and keep the take when the input or the disk fails
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.
2026-10-12 23:33:48 +01:00
grovebit 334cf40506 Autosave after every change, on a thread of its own
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.
2026-10-12 23:32:47 +01:00
grovebit 98d543f0d8 Go silent instead of crashing when the audio engine hits a bug
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.
2026-10-12 23:32:47 +01:00
grovebit 5fff2e8c78 Keep working when the interface hits a bug
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.
2026-10-12 23:32:47 +01:00
grovebit b5ea465274 Autosave each session to its own folder and offer unsaved work at launch
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.
2026-10-12 23:32:47 +01:00
woodgrain816andClaude Opus 5.5 1a49fc70fb File › Open Recent: the last 10 sessions, newest first
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>
2026-10-12 17:31:22 -05:00
grovebit 6fd17d7c0e Keep shortcuts working after Tab
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.
2026-10-12 23:16:11 +01:00
Fabio LussiandClaude Opus 5.5 3432b52373 Keep later meter changes on their bars when an earlier meter is edited
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>
2026-10-12 23:52:50 +02:00
Fabio LussiandClaude Opus 5.5 81ec430341 fix(meter): keep K-weighting stable below ~3.4 kHz sample rates
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>
2026-10-12 23:45:03 +02:00
grovebit 1953f2ecc6 Track height: resize live while dragging, Pro Tools modifiers and Ctrl+Up/Down
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.
2026-10-12 22:11:34 +01:00
webdevtodayjason 950f3e7085 Keep clips in order after Trim to Selection 2026-10-12 16:09:01 -05:00
grovebit 6f8f1e7692 Simplify the Edit window's track rows and plugin slots
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.
2026-10-12 22:04:41 +01:00
grovebit 0b078d2f22 Open a send's level only from its fader icon
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.
2026-10-12 22:04:25 +01:00
grovebit d26d6fec77 Lay out Edit window tracks and plugin slots like Pro Tools
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.
2026-10-12 22:03:32 +01:00
grovebit 766dbc4845 Merge send-level into pt-track-headers 2026-10-12 22:01:49 +01:00
grovebit 72288fc77e Make the Shuffle, Slip, Spot and Grid edit modes work like Pro Tools
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.
2026-10-12 21:49:31 +01:00
Sajidur RahmanandClaude Opus 5.5 7a62ab3be0 Add Clip › New MIDI Clip menu entry and command reachability test
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>
2026-10-13 02:44:59 +06:00
highperfocused d6ea8cf001 fix(ui-egui): own the test palette lock for every SoundApp lifetime
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
2026-10-12 22:43:33 +02:00
webdevtodayjason c39773b62d Keep automation thinning within its tolerance 2026-10-12 15:32:48 -05:00
webdevtodayjason 34d8488284 Keep the clip gain line where a clip is trimmed or separated 2026-10-12 15:32:40 -05:00
webdevtodayjason 8716dc9025 Add Jason Brashear to the contributor names 2026-10-12 15:32:40 -05:00
grovebit a47ce1cee5 Waveforms like Pro Tools': exact per-pixel peaks and working view options
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.
2026-10-12 21:18:25 +01:00
grovebit 921a36aa45 Clicking a send's level opens its Send window with a fader
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").
2026-10-12 21:17:36 +01:00
grovebit 3617011342 Pro Tools keys for nudging, Grid and Nudge values, playlists and windows
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.
2026-10-12 21:17:26 +01:00
grovebit bebca3aa85 Draw grid lines over clips in the Edit window
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.
2026-10-12 20:43:09 +01:00
highperfocused c8021d4c90 fix(mix): mix.insert_params rejects unknown parameter names
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
2026-10-12 21:41:43 +02:00
grovebit c9b694907f Mark the Tracks panel's edge with a grey scroll bar
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.
2026-10-12 20:27:53 +01:00
grovebit 312315acb9 Type to search as soon as a plugin or bus menu opens
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.
2026-10-12 20:21:55 +01:00
reoniazx 5ec8c21b19 Preview waveform amplitudes during fade drags 2026-10-13 02:48:00 +08:00
reoniazx 79e30c39d8 Smooth fade curves and retain draggable midpoint controls 2026-10-13 02:48:00 +08:00
reoniazx 4ac47b3a7f Add draggable midpoint curvature for clip fades 2026-10-13 02:48:00 +08:00
reoniazx d5e6fa32ef Enable dragging existing fades from their curve endpoints 2026-10-13 02:48:00 +08:00
reoniazx db23f873fd Match fade drag previews to the committed curve 2026-10-13 02:47:59 +08:00
reoniazx d9922c47a6 Fix clip fade drag bounds and targeted fade creation 2026-10-13 02:47:59 +08:00
grovebit 57682b03ef Merge silver-light into pt-track-headers 2026-10-12 19:24:54 +01:00
grovebit 3189a1a512 Tint each Edit window track with its colour
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.
2026-10-12 19:23:53 +01:00
grovebit 4c38ef59c1 Silver Light theme
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.
2026-10-12 19:22:44 +01:00
grovebit 6e361262e1 Hardware-style knobs, faders, keys and meters
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.
2026-10-12 19:20:02 +01:00
grovebit 2e5fc17f69 Merge edit-plugin-columns into controls-look 2026-10-12 19:13:27 +01:00
grovebit e53b772aea Open, add and change plugins from the Edit window
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.
2026-10-12 19:11:33 +01:00
grovebit 66caedd923 Line up stereo Mix strips with mono ones
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.
2026-10-12 19:06:19 +01:00
grovebit 6d03eab566 Make the fader scale readable in the Light theme
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.
2026-10-12 19:06:13 +01:00
grovebit 6b3c73b7ff Decode each audio file once when a session names it many times
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.
2026-10-12 18:40:12 +01:00
grovebit 328760fd70 Keep positions from files within the range commands use
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.
2026-10-12 18:40:12 +01:00
grovebit fe1d7e4c58 Refuse MIDI files that run longer than a day
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.
2026-10-12 18:40:12 +01:00
grovebit 1b8baa10af Keep Extract Chords to a bounded walk over the selection
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.
2026-10-12 18:40:12 +01:00
grovebit 7ca978fdb9 Cut names from files to a length that can be drawn
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.
2026-10-12 18:40:12 +01:00
grovebit a8a36540d6 Name thousands of same-named tracks and files in linear time
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.
2026-10-12 18:40:12 +01:00
grovebit d81d5756d4 Check an import's size at the session's sample rate
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.
2026-10-12 18:40:12 +01:00
grovebit 7f4ab5c76b Refuse devices and pipes where a file is expected
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".
2026-10-12 18:40:12 +01:00
grovebit 1617733a98 Repair a session's sample rate and tempo map when it loads
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.
2026-10-12 18:40:12 +01:00
grovebit de5f2e0fb3 Check INFO entry sizes in WAV files that symphonia reads
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.
2026-10-12 18:39:48 +01:00
grovebit 777c08ef1a Skip ID3 tags instead of having symphonia read them
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.
2026-10-12 18:39:48 +01:00
grovebit a4213cf90f Check FLAC picture and comment lengths before symphonia reads them
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.
2026-10-12 18:39:48 +01:00
grovebit d64d5f491e Refuse files whose packets claim millions of frames
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.
2026-10-12 18:39:48 +01:00
grovebit 37f240ed54 Don't crash on MP4 files that claim more data than they hold
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.
2026-10-12 18:39:48 +01:00
grovebit c40078f32a Don't crash on CAF files that claim more data than they hold
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.
2026-10-12 18:39:48 +01:00
grovebit 0a5fc6ccba Say at the Record button why Record did nothing
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.
2026-10-12 18:33:03 +01:00
Limestudio90andClaude Opus 5.5 acaa91008c mix: a newly adopted instance replaces a matching one in its slot
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>
2026-10-12 19:27:12 +02:00
grovebit 5e7309537e Make an automation pass one undo step, its fader ride included
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.
2026-10-12 18:13:55 +01:00
grovebit dab2bb956c Don't let Strip Silence delete clips past what one render holds
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.
2026-10-12 18:12:00 +01:00
grovebit edf4fc59f6 Right-click a track's name for the track menu, in Edit and Mix
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.
2026-10-12 18:07:47 +01:00
grovebit 7ea64c1190 Send levels drag down to −∞, reset with Alt-click and don't stick
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.
2026-10-12 18:03:36 +01:00
Limestudio90andClaude Opus 5.5 992d715493 Docs: Mix in a separate window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:58:10 +02:00
Limestudio90andClaude Opus 5.5 9e84e0e351 Close plugin editors before reopening the audio device
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>
2026-10-12 18:57:05 +02:00
grovebit fe3d759b53 Add tracks from the Tracks list and the empty space below the tracks
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.
2026-10-12 17:52:12 +01:00
Limestudio90andClaude Opus 5.5 2e3389178e Mix in a separate window: Edit and Mix on screen at once
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>
2026-10-12 18:47:18 +02:00
grovebit 5026b0655d Signal Generator fills the selection with a new clip
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.
2026-10-12 17:38:17 +01:00
grovebit 3a702d9c6f Refuse renders too long to finish instead of cutting the audio short
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.
2026-10-12 17:35:33 +01:00
Ian MartinandClaude Opus 5.5 ced57be723 vst3-host: the late-exit test expects Windows' exit order
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>
2026-10-12 10:34:41 -06:00
Ian MartinandClaude Opus 5.5 f55010d244 vst3-host: fixture builds on Windows (bus flags are i32 there)
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>
2026-10-12 10:34:41 -06:00
Ian MartinandClaude Opus 5.5 b2f6361265 engine: build the expected missing-file path the way the engine does
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>
2026-10-12 10:34:41 -06:00
Ian MartinandClaude Opus 5.5 218cbb6670 vst3-host: build the symlink test on Unix only
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>
2026-10-12 10:34:41 -06:00
Ian MartinandClaude Opus 5.5 557553cb06 video: collapse the MP3 esds branch into a match guard
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>
2026-10-12 10:34:41 -06:00
NetterSilver 6d626adf9f Docs: update the progress docs for this change 2026-10-12 18:31:37 +02:00
NetterSilver ec41f804ff Docs: update the progress docs for this change 2026-10-12 18:26:37 +02:00
Limestudio90andClaude Opus 5.5 a1e9660e60 Audio device settings: system, devices, sample rate, buffer size; opt-in ASIO
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>
2026-10-12 18:22:41 +02:00
grovebit 35609b756b Make the Zoom Preset, Waveform Zoom and Clip Rating keys work; add Commands Focus 1-5 and N
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.
2026-10-12 17:19:46 +01:00
NetterSilver 8c73d9c8b3 Docs: update the progress docs for this change 2026-10-12 18:15:38 +02:00
Limestudio90andClaude Opus 5.5 434c60047d clap-host: host embedded plugin editors on Windows
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>
2026-10-12 17:44:36 +02:00
pi 23b7e023b6 docs: fix documentation (grammar-minor-008) 2026-10-13 04:35:20 +13:00
pi 89c7c9a0b0 docs: fix documentation (grammar-minor-007) 2026-10-13 04:35:07 +13:00
pi 9f4335388e docs: fix documentation (grammar-minor-002) 2026-10-13 04:34:49 +13:00
Limestudio90andClaude Opus 5.5 b1cb6cb368 vst3-host: host plugin editors on Windows
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>
2026-10-12 17:24:26 +02:00
Brandon ThomasandClaude Opus 5.5 6cf896e76c Release 0.6.1: rebuild with the fixed shared release workflow
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>
2026-10-12 11:11:17 -04:00
grovebit c9e53ee704 Look up clip effects and Real-Time Properties without allocating
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.
2026-10-12 16:02:37 +01:00
grovebit 2d42cf278c Free replaced session snapshots on the UI thread
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.
2026-10-12 16:02:29 +01:00
grovebit 56ffce6dec Take a plugin that puts out NaN out of the mix instead of silencing it
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.
2026-10-12 15:50:58 +01:00
grovebit 4c21ff8fe4 Keep a plugin's saved settings when it refuses to load them
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.
2026-10-12 15:50:58 +01:00
grovebit 793432bbfc Read plugin state on the UI thread, not the audio thread
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.
2026-10-12 15:50:58 +01:00
Isaac Levine 719fd909ee Docs: document --automation-read-root and --automation-write-root for mcp 2026-10-12 10:50:05 -04:00
Isaac Levine a9fa027528 CLI: add --automation-read-root and --automation-write-root to mcp 2026-10-12 10:50:05 -04:00
Isaac Levine 57baab86d4 MCP: confine the headless session to the automation roots 2026-10-12 10:50:05 -04:00
Isaac Levine 29497296a1 Engine: check automation read and write roots on every path a command reads or writes 2026-10-12 10:50:04 -04:00
Marco Arnulfo 4bacfb13d5 Apply MIDI track offsets when scheduling notes (fixes #234)
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.
2026-10-12 16:48:50 +02:00
Marco Arnulfo d5f069417e Keep the last selection out of the document so selecting leaves the session clean 2026-10-12 16:47:56 +02:00
grovebit 4b98064e46 Automation passes replace the automation they play over
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.
2026-10-12 15:47:08 +01:00
Marco Arnulfo 1f7a4ab3e4 Restore Last Selection brings back the previous selection (fixes #231) 2026-10-12 16:42:19 +02:00
Marco Arnulfo 4235def686 Locate clears plugin and delay-compensation state (fixes #247) 2026-10-12 16:40:16 +02:00
Scott Campbell 7d8c185d13 Add a sampler UI: sample strip, drag and drop, instrument sliders
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.
2026-10-12 10:39:45 -04:00
Marco Arnulfo 3765a32b9a fix(mix): pan automation reads the same in playback and bounce (fixes #244) 2026-10-12 16:36:59 +02:00
Marco Arnulfo 5e7ca6de22 Apply the Feet+Frames reference to counters and typed positions (fixes #235)
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.
2026-10-12 16:36:18 +02:00
MarekSpodBiedry dabcb4840a Open .m4b audiobook files like .m4a
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.
2026-10-12 16:27:57 +02:00
grovebit eb5bf699c7 Bounce Mix: Browse for the file, and start in the session folder
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.
2026-10-12 15:13:25 +01:00
grovebit 57edb43856 Rename a marker by double-clicking it in the ruler
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.
2026-10-12 15:07:37 +01:00
grovebit 5a156c2468 Drag or type the tempo in the toolbar
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.
2026-10-12 15:02:11 +01:00
grovebit 7a58e49775 Reorder tracks by dragging them in the Tracks list
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.
2026-10-12 14:55:40 +01:00
Marco Arnulfo 4e14c980a4 fix(edit): trims stop at the neighbouring clips instead of overlapping them (#228, #229)
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.
2026-10-12 15:50:38 +02:00
grovebit cf9cced90d Create MIDI clips with the Pencil, Consolidate and the MIDI Editor
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).
2026-10-12 14:49:51 +01:00
Marco Arnulfo e5d5996f7d fix(event): Constant tempo applies only over its range (#238) 2026-10-12 15:36:40 +02:00
Marco Arnulfo 93ddd419f5 fix(track): Clear Trim Automation removes the trim lane (#233) 2026-10-12 15:24:32 +02:00
Marco Arnulfo c85952ecc3 fix(edit): cut clip gain keeps the removed gain for paste (#230) 2026-10-12 15:17:52 +02:00
Marco Arnulfo 5f4cc96578 fix(midi-editor): Backspace in a text field leaves the selected notes alone (#223) 2026-10-12 15:13:18 +02:00
Marco Arnulfo fe919e5d57 fix(clip): Elastic Properties stretches the clip by the requested ratio (#227) 2026-10-12 14:46:05 +02:00
Marco Arnulfo 96ea49c7f6 fix(dsp): chorus and flanger tails follow their feedback (#242) 2026-10-12 14:44:57 +02:00
Marco Arnulfo ae46052cac fix(dsp): pitch_shift reaches ±48 semitones (#243) 2026-10-12 14:34:02 +02:00
Marco Arnulfo 35e9d63bee fix(markers): markers.edit applies the documented name (#236) 2026-10-12 14:30:57 +02:00
Marco Arnulfo 9144570b6e fix(video): H.264 parameter sets check their arithmetic before range checks (#251, #252) 2026-10-12 12:38:03 +02:00
Marco Arnulfo c73a8e2272 fix(eq): switches and choices redesign the filter on a running EQ (#239) 2026-10-12 12:34:35 +02:00
Marco Arnulfo f792eae580 fix(midi-editor): Quantize snaps to the grid the editor shows (#226) 2026-10-12 12:32:41 +02:00
Marco Arnulfo 0d18612a87 fix(edit-window): Pencil clicks on plugin lanes use the plugin's range (#224) 2026-10-12 12:30:02 +02:00
Marco Arnulfo bff9ca1390 fix(window): Hide All Floating Windows also hides Automation (#253) 2026-10-12 12:18:00 +02:00
Marco Arnulfo 89ff513b78 fix(video-universe): filmstrip thumbnails use the clip's own movie time (#225) 2026-10-12 12:15:28 +02:00
Marco Arnulfo 7d173d3a25 fix(midi-editor): a selected video clip is not a MIDI target (#258) 2026-10-12 12:13:21 +02:00
Marco Arnulfo 4c4414d006 fix(window): keep the MIDI Editor open on Bring to Front (#254) 2026-10-12 11:58:36 +02:00
Marco Arnulfo 67158020f0 fix(control): apply the engine.commands filter to UI commands (#255) 2026-10-12 11:56:06 +02:00
Brandon ThomasandClaude Opus 5.5 df0c4790b7 Release prep: 0.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:38 -04:00
Marco Arnulfo 30372886cf fix(track): vca: null clears a VCA assignment, as documented (#237) 2026-10-12 11:03:28 +02:00
Marco Arnulfo 217bea462d fix(dsp): a huge finite sample rate is no pitch, not an overflow (#241) 2026-10-12 10:59:23 +02:00
TheMojoMan 5e1c247eda fix(ui): horizontally scroll the Edit toolbar
Groups clipped when the window was narrower than the toolbar; wrap the whole row in a horizontal ScrollArea so every tool stays reachable.
2026-10-12 10:10:00 +02:00
NetterSilver faf2a13468 Add my contributor entry 2026-10-12 09:07:02 +02:00
NetterSilver 9f1a62470d Reject invalid --rate and --bit-depth in convert (#256)
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.
2026-10-12 08:54:26 +02:00
NetterSilver e1e15cf3ab Compute timecode frame counts in i128 and clamp (#248)
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.
2026-10-12 08:53:51 +02:00
NetterSilver cfce845f70 Saturate the bar offset for bars before 1 (#249)
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.
2026-10-12 08:52:59 +02:00
Jafz2001 87b348fecc Complete routing folder management 2026-10-11 23:50:06 -03:00
grovebit acd9e7d54e Play MIDI controllers through instruments
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.
2026-10-12 02:57:22 +01:00
grovebit 33ad8264c8 Give plugins the session's tempo and transport
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.
2026-10-12 02:56:55 +01:00
grovebit 75fae5535f Share the session with the UI instead of copying it every frame
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.
2026-10-12 02:31:02 +01:00
Brandon Thomas acb08ff5f0 Merge pull request #108 from scottcampbelldata/sampler-instrument
Add a built-in Sampler instrument
2026-10-11 21:06:08 -04:00
Fabio LussiandClaude Opus 5.5 092b3cc890 Consolidate covers the whole edit selection
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>
2026-10-12 03:02:56 +02:00
Brandon Thomas b9dec547df Merge remote-tracking branch 'origin/main' into sampler 2026-10-11 21:02:27 -04:00
Brandon Thomas 525f00b719 Merge pull request #157 from grovebit/unsaved-changes
Ask before discarding unsaved changes; Save opens Save As for new sessions
2026-10-11 20:59:57 -04:00
Brandon Thomas 48c3f65e12 Merge pull request #160 from grovebit/menu-fixes
Clip > Rename opens its dialog; Undo and Redo name their step; the Window menu's keys work
2026-10-11 20:59:52 -04:00
Brandon Thomas 6754f04a05 Merge pull request #192 from grovebit/idle-repaint
Let the desktop app sleep when idle or covered
2026-10-11 20:59:48 -04:00
Brandon Thomas 7f2a6977b5 Merge pull request #56 from grovebit/trackpad-scrolling
Trackpad-friendly Edit window: smooth panning, pinch and Cmd+scroll zoom
2026-10-11 20:59:45 -04:00
Brandon Thomas c5a8fd2f52 Merge pull request #49 from grovebit/drag-selection
Fix drag selection and the insertion point in the Edit window
2026-10-11 20:59:41 -04:00
Brandon Thomas 7c7e8deaaa Merge pull request #158 from natocool2/fix/web-files
Web: open, import, save and export files in the browser (fixes #63, fixes #64)
2026-10-11 20:59:37 -04:00
Brandon ThomasandClaude Opus 5.5 6347aad211 Rebuild on the amended drag-selection (#49)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:57:17 -04:00
Brandon ThomasandClaude Opus 5.5 c5efedd740 Merge drag-selection (#49) into trackpad
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:57:06 -04:00
Brandon ThomasandClaude Opus 5.5 ef0ce90b66 Merge origin/main into drag-selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:56:43 -04:00
Brandon ThomasandClaude Opus 5.5 2ec2698331 Grid moves snap the grabbed clip, not whatever is under the pointer now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:56:37 -04:00
Brandon ThomasandClaude Opus 5.5 f855836af1 Idle test: playing repaints at the adaptive meter rate from #207
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:51:00 -04:00
Brandon ThomasandClaude Opus 5.5 7a1035a3d5 Merge origin/main into sampler-instrument
#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>
2026-10-11 20:45:12 -04:00
Brandon Thomas 480b0b2dd7 Merge pull request #211 from storytold/fmt-send-doc
rustfmt the mix.send spec
2026-10-11 20:43:32 -04:00
Brandon ThomasandClaude Opus 5.5 b01abf6dd7 rustfmt the mix.send spec
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:43:27 -04:00
Brandon ThomasandClaude Opus 5.5 d37386721a Merge origin/main into unsaved-changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:42:42 -04:00
Brandon ThomasandClaude Opus 5.5 1c52ad1c6e Merge origin/main into idle-repaint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:42:34 -04:00
Brandon ThomasandClaude Opus 5.5 e7e4a19cfa Merge origin/main into fix/web-files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:42:26 -04:00
Brandon Thomas 18f81b037b Merge branch 'edit-drag' into trackpad 2026-10-11 20:41:55 -04:00
Brandon ThomasandClaude Opus 5.5 ad953ed82d Merge origin/main into drag-selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:41:55 -04:00
Brandon Thomas 03bebb572d Merge remote-tracking branch 'origin/main' into menu-fixes 2026-10-11 20:41:39 -04:00
Brandon Thomas 5a34f4118c Merge pull request #200 from Jafz2001/fix/mixer-routing-qol
Improve mixer routing menus and numeric controls
2026-10-11 20:41:06 -04:00
Brandon Thomas 59476adfd3 Merge pull request #202 from Jafz2001/fix/macos-plugin-editor-windows
Keep macOS plugin editors out of the Dock
2026-10-11 20:41:02 -04:00
Brandon Thomas 2d7311197d Merge pull request #188 from grovebit/window-fixes
Scroll long plugin windows; reopen the main window at its last size and scale
2026-10-11 20:40:58 -04:00
Brandon Thomas 4e846b8f02 Merge pull request #75 from grovebit/clip-list-delete
Clear clips and audio files from the Clip List (#4)
2026-10-11 20:40:53 -04:00
Brandon Thomas 3e97e49bfb Merge pull request #70 from grovebit/export-clips-shortcut
Shift+Cmd+K exports clips as files
2026-10-11 20:40:48 -04:00
Brandon Thomas ef04444f69 Merge pull request #84 from grovebit/record-to-disk
Record takes straight to disk
2026-10-11 20:40:44 -04:00
Brandon Thomas 4e1c18f949 Merge pull request #181 from rollroyces/fix/soundcraft-152-instrument-level-bounce
fix(mix): push an instrument's stored params to its plugin before each block (#152)
2026-10-11 20:40:40 -04:00
Brandon Thomas 19fb808b2b Merge pull request #195 from grovebit/save-safety
Save safety: never overwrite audio or sessions, sync saves and keep backups
2026-10-11 20:40:36 -04:00
Brandon ThomasandClaude Opus 5.5 3055cbb0c0 Measure the #152 regression test past parameter smoothing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:37:56 -04:00
Brandon ThomasandClaude Opus 5.5 e27a7e8ea4 Merge origin/main into idle-repaint
Keep the adaptive repaint rates from #207 and meters_live() from #187 in ui().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:34:51 -04:00
Brandon ThomasandClaude Opus 5.5 b25a49ee5a Merge origin/main into fix/mixer-routing-qol
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:34:16 -04:00
grovebit 157e2a9547 Fix drag selection and the insertion point in the Edit window
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.
2026-10-12 01:32:39 +01:00
Brandon ThomasandClaude Opus 5.5 638f32d705 Merge drag-selection (#49) into trackpad
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:22:45 -04:00
Brandon ThomasandClaude Opus 5.5 deea994688 Grid moves snap the grabbed clip, not whatever is under the pointer now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:22:24 -04:00
grovebit e26a5f5f42 Zoom Toggle: E zooms in on the edit selection, E again zooms back out
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.
2026-10-12 01:22:08 +01:00
Brandon Thomas 050bfbd140 Merge remote-tracking branch 'origin/main' into edit-drag 2026-10-11 20:22:04 -04:00
Brandon Thomas 7c26ea4f44 Merge remote-tracking branch 'origin/main' into clear-clips 2026-10-11 20:21:45 -04:00
Brandon ThomasandClaude Opus 5.5 3b947b94bd Deselect All also clears the Clip List's file selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:21:45 -04:00
Brandon ThomasandClaude Opus 5.5 7b2f1ac200 Merge origin/main into export-clips-shortcut
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:20:34 -04:00
Brandon Thomas 69f76ca0a5 Merge remote-tracking branch 'origin/main' into rec-disk 2026-10-11 20:19:50 -04:00
Brandon ThomasandClaude Opus 5.5 d87d0373ee Record to disk: keep a partial frame between pops so channels never shift
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:19:50 -04:00
Brandon ThomasandClaude Opus 5.5 6a35fcd3a9 Re-targeting a send keeps its level, pan, mute and pre/post
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:19:19 -04:00
Brandon ThomasandClaude Opus 5.5 df49660bb5 Merge origin/main into window-fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:18:16 -04:00
Brandon ThomasandClaude Opus 5.5 df70f6ad69 Ignore a corrupt saved window size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:18:11 -04:00
Brandon ThomasandClaude Opus 5.5 ec920cd975 Instrument params: never read insert slot A's automation; regression tests for #152
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:17:53 -04:00
Brandon ThomasandClaude Opus 5.5 8f432ee0c1 Merge origin/main into fix/web-files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:16:27 -04:00
Brandon ThomasandClaude Opus 5.5 69570e5b54 Cap session bundles at 1 GiB: 4 GiB is all of wasm32's address space
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:16:15 -04:00
Brandon ThomasandClaude Opus 5.5 66bf162aa9 Drop UtilityWindow: AppKit documents it only for NSPanel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:15:57 -04:00
Brandon ThomasandClaude Opus 5.5 8d294c08f8 Merge origin/main into save-safety
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>
2026-10-11 20:15:56 -04:00
Brandon Thomas cc36a3eba6 Merge remote-tracking branch 'origin/main' into menu-fixes 2026-10-11 20:14:54 -04:00
Brandon Thomas a10eb47032 Merge pull request #187 from grovebit/meter-fixes
Keep armed tracks' meters live while stopped; make Clear All Clip Indicators work
2026-10-11 20:13:31 -04:00
SteamSmithandClaude Opus 5.5 4fd015f836 control server: bound request lines and concurrent connections
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>
2026-10-11 20:13:09 -04:00
Brandon ThomasandClaude Opus 5.5 fc095e7fe5 Merge origin/main into meter-fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:05:57 -04:00
Brandon Thomas 902220e4d4 Merge pull request #148 from grovebit/heal-shortcut
Cmd+H heals instead of hiding SoundCraft on macOS (#140)
2026-10-11 20:05:09 -04:00
Brandon Thomas a77c351da3 Merge pull request #147 from grovebit/mix-edit-toggle-zoom
Cmd+= only switches Mix/Edit, and the Interface scale slider drags smoothly
2026-10-11 20:05:05 -04:00
Brandon Thomas 5243f9a2a8 Merge pull request #208 from Jafz2001/perf/reuse-mix-buffers
perf: reuse realtime mix buffers
2026-10-11 20:05:01 -04:00
Brandon Thomas ce8eda7575 Merge pull request #207 from Jafz2001/perf/adaptive-meters
perf: adapt meter polling and repaint rate
2026-10-11 20:04:56 -04:00
Brandon Thomas bb1048e0df Merge pull request #203 from storytold/release-pass-azure-secrets
Release workflow: pass the Azure signing secrets
2026-10-11 20:04:52 -04:00
grovebit 5f122fb30d Fix drag selection and the insertion point in the Edit window
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.
2026-10-12 01:04:47 +01:00
Brandon Thomas f68e6c438a Merge pull request #198 from itrycodinganditsbad/fxc
Windows: compile DX12 shaders with FXC
2026-10-11 20:04:47 -04:00
Brandon Thomas 6122dac051 Merge pull request #197 from itrycodinganditsbad/dmg-srcfolder
macOS: build the DMG with hdiutil create so the app passes codesign --strict
2026-10-11 20:04:43 -04:00
Brandon Thomas 1cf2dfd781 Merge pull request #178 from storytold/ci-speedup-nightly-platform-checks
CI: FreeBSD and Windows ARM64 nightly and on release, not per PR
2026-10-11 20:04:40 -04:00
Brandon Thomas 3f44d4ed43 Merge pull request #177 from coygeek/fix/soundcraft-light-instrument-contrast-20261010
fix(theme): restore Light Mix Instrument label contrast
2026-10-11 20:04:35 -04:00
Brandon Thomas 9bd609a572 Merge pull request #146 from salignatmoandal/fix/mcp-batch-keep-going-iserror
MCP batch: surface failures when keep_going is set (#145)
2026-10-11 20:04:31 -04:00
Brandon Thomas 99b3ffe48a Merge pull request #135 from vbasky/credit/vbasky-name
Credit Vikram Bhaskaran by real name and display name.
2026-10-11 19:51:00 -04:00
Brandon Thomas 3731299851 Merge pull request #134 from vbasky/fix/104-volume-to-clip-gain
Convert only the selected volume automation into clip gain.
2026-10-11 19:50:56 -04:00
Brandon Thomas 7442bc9bb1 Merge pull request #133 from vbasky/fix/103-aux-latency
Compensate for an aux insert's latency on the same block that measures it.
2026-10-11 19:50:52 -04:00
Brandon Thomas f1ff34f533 Merge pull request #132 from vbasky/fix/92-clip-gain-stretch
Bake varispeed into rendered clip gain, then stop stretching it again.
2026-10-11 19:50:49 -04:00
Brandon Thomas 0f32d4d4cc Merge pull request #131 from vbasky/fix/59-bounce-bus
Render a named bus when bouncing, and reject an unknown one.
2026-10-11 19:50:45 -04:00
Brandon Thomas c716584e19 Merge pull request #130 from vbasky/fix/88-audiosuite-gain
Keep clip gain when AudioSuite inverts or duplicates.
2026-10-11 19:50:42 -04:00
Brandon Thomas a8452b908c Merge pull request #129 from vbasky/fix/101-playback-speed
Restore full playback speed when transport stops.
2026-10-11 19:50:38 -04:00
Brandon Thomas f935d6ee79 Merge pull request #127 from vbasky/fix/95-cli-command-ids
Treat dotted command ids as engine.execute in the CLI app shorthand.
2026-10-11 19:50:34 -04:00
Brandon Thomas ceae137642 Merge pull request #126 from vbasky/fix/96-closed-stdout
Keep going when the CLI's stdout is closed.
2026-10-11 19:50:30 -04:00
Brandon Thomas 776b771f59 Merge pull request #128 from vbasky/fix/89-surround-import
Import multichannel audio onto a track of the same width.
2026-10-11 19:49:37 -04:00
Brandon Thomas b76161b698 Merge pull request #125 from vbasky/fix/87-midi-export
Export MIDI controllers and honour the tracks filter.
2026-10-11 19:49:34 -04:00
Brandon Thomas cc0eb1caf5 Merge pull request #124 from vbasky/fix/91-save-copy
Save a session copy without retargeting the open document.
2026-10-11 19:49:31 -04:00
Brandon ThomasandClaude Opus 5.5 adb14703e4 Merge origin/main into fix/89-surround-import
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:49:11 -04:00
Brandon ThomasandClaude Opus 5.5 cb655d9ec4 Merge origin/main into fix/87-midi-export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:49:07 -04:00
Brandon ThomasandClaude Opus 5.5 6a12573e4f Merge origin/main into fix/91-save-copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:49:02 -04:00
Brandon Thomas cffc932e57 Merge pull request #123 from vbasky/fix/86-consolidation-files
Keep each consolidation in its own audio file.
2026-10-11 19:48:51 -04:00
Brandon ThomasandClaude Opus 5.5 e7c31321f3 Volume to clip gain: keep the level outside the selection; keep clip gain points; ramps reach their end
- 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>
2026-10-11 19:44:08 -04:00
Brandon ThomasandClaude Opus 5.5 55dfcc95d1 Close the export-clips test the rebase left open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:43:41 -04:00
Brandon ThomasandClaude Opus 5.5 75430d2472 Close the export-clips test the rebase left open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:43:41 -04:00
Brandon ThomasandClaude Opus 5.5 113292cc1a Close the export-clips test the rebase left open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:43:40 -04:00
Jafz2001 9850a41f29 perf: reuse realtime mix buffers 2026-10-11 19:36:39 -03:00
Jafz2001 eddc846f51 perf: adapt meter polling and repaint rate 2026-10-11 19:33:37 -03:00
Jafz2001 d552da531f fix: keep macOS plugin editors out of the Dock 2026-10-11 19:03:38 -03:00
Brandon ThomasandClaude Opus 5.5 e60c41c2c8 Release workflow: pass the Azure signing secrets so Windows builds are signed
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>
2026-10-11 18:03:21 -04:00
Jafz2001 b9345a3576 feat: improve mixer routing and numeric controls 2026-10-11 18:29:02 -03:00
Fabio LussiandClaude Opus 5.5 e102ed3656 Windows: compile DX12 shaders with FXC
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>
2026-10-11 22:37:56 +02:00
Fabio LussiandClaude Opus 5.5 27d934e726 macOS: build the DMG with hdiutil create so the app passes codesign --strict
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>
2026-10-11 22:09:39 +02:00
grovebit 18a2558104 Ask to save changes before closing a modified session
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
2026-10-11 20:13:48 +01:00
grovebit 54e3761d62 Open sessions whose plugin parameters were saved as null
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.
2026-10-11 20:03:47 +01:00
grovebit 153381e03b Flush saves to disk and keep backups of the session file
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.
2026-10-11 20:03:47 +01:00
grovebit 62a0b683e5 Ask before Save As replaces another session
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.
2026-10-11 20:03:47 +01:00
grovebit 86824b592a Never overwrite audio files when saving
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.
2026-10-11 19:59:43 +01:00
grovebit d7c3818859 Let the desktop app sleep when idle or covered
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%.
2026-10-11 17:31:26 +01:00
Dylan M. Taylor 240aa848a0 ci: refactor workflows to use storytold/craft-common-actions (#184)
* 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
2026-10-11 11:52:00 -04:00
grovebit 534b44d430 Reopen the window at its last size and interface scale
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.
2026-10-11 15:57:39 +01:00
grovebit 992f60c79c Trackpad-friendly Edit window: smooth panning, pinch and Cmd+scroll zoom
- 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.
2026-10-11 15:21:46 +01:00
grovebit 7208c610cd Make Clear All Clip Indicators clear them
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.
2026-10-11 14:10:10 +01:00
grovebit b2a3b0f861 Keep armed tracks' meters live while the transport is stopped
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.
2026-10-11 14:10:10 +01:00
grovebit 16b7c38b6b Scroll a plugin window's parameters when they don't fit
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.
2026-10-11 14:09:03 +01:00
Royce 99992fe78c fix(mix): push an instrument's stored params to its plugin before each block (#152)
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.
2026-10-11 15:35:02 +08:00
Coy Geek 592991789e fix(theme): restore Light Mix Instrument label contrast
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
2026-10-10 20:46:49 -07:00
Vikram Bhaskaran 849cc79675 Keep each consolidation in its own audio file.
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.
2026-10-11 08:49:20 +11:00
Vikram Bhaskaran 2afbfd9477 Save a session copy without retargeting the open document.
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.
2026-10-11 08:49:18 +11:00
Vikram Bhaskaran 09d64ab2ec Export MIDI controllers and honour the tracks filter.
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.
2026-10-11 08:49:08 +11:00
Vikram Bhaskaran 404940b40d Import multichannel audio onto a track of the same width.
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.
2026-10-11 08:48:52 +11:00
grovebit 6b98c21fed Make the Window menu's shortcuts work
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.
2026-10-10 19:27:16 +01:00
grovebit bd00c3855c Name the step in the Edit menu's Undo and Redo
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.
2026-10-10 19:27:16 +01:00
grovebit aa4f038f1a Open the Rename Clip dialog from Clip > Rename
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.
2026-10-10 19:27:16 +01:00
natocool2andClaude Opus 5.5 6964c50d00 Web: open, import, save and export files in the browser (fixes #63, fixes #64)
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>
2026-10-10 19:17:07 +01:00
grovebit 718bb5c7fb Open Save As when saving a session that has never been saved
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.
2026-10-10 14:20:55 +01:00
grovebit 4aa837e933 Make Cmd+H heal and Cmd+Alt+H copy to send on macOS
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.
2026-10-10 13:38:23 +01:00
grovebit 840939faf7 Cmd+= only switches Mix/Edit; the scale slider drags smoothly
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.
2026-10-10 13:37:19 +01:00
Mawen Salignat-Moandal 15fea1ed5d MCP batch: set isError when any call fails, even with keep_going.
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.
2026-10-10 14:23:08 +02:00
grovebit 9ed4bdd4b1 Shift+Cmd+K exports clips as files
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.
2026-10-10 11:45:22 +01:00
Vikram Bhaskaran 7b25efd8cd Credit Vikram Bhaskaran by real name and display name.
The About window can show either name when the viewer picks that label. The GitHub profile name stays unused.
2026-10-10 17:38:55 +11:00
Vikram Bhaskaran 59721f7c87 Compensate for an aux insert's latency on the same block that measures it.
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.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran fe4f59a856 Bake varispeed into rendered clip gain, then stop stretching it again.
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.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran abcec3938b Render a named bus when bouncing, and reject an unknown one.
file.bounce_mix advertised a source but always wrote the main mix, so an empty bus and a missing bus both succeeded with the whole mix.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran ec33d4011b Keep clip gain when AudioSuite inverts or duplicates.
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.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran 35a6fbccc0 Restore full playback speed when transport stops.
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.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran 1aad99ff4c Convert only the selected volume automation into clip gain.
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.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran 734c8e18bc Treat dotted command ids as engine.execute in the CLI app shorthand.
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.
2026-10-10 17:38:53 +11:00
Vikram Bhaskaran c4a88ee64a Keep going when the CLI's stdout is closed.
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.
2026-10-10 17:38:53 +11:00
Scott Campbell fa88fbb942 Add a built-in Sampler instrument
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.
2026-10-09 22:55:24 -04:00
grovebit cfe6b42d00 Record takes straight to disk
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.
2026-10-10 02:22:18 +01:00
grovebit 42d08558ab Clear clips and audio files from the Clip List
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.
2026-10-10 02:07:51 +01:00
503 changed files with 66423 additions and 20491 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+38
View File
@@ -0,0 +1,38 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
# craft-ui is on egui 0.36 until storytold/craft-libs#199 lands.
ignore:
- dependency-name: "egui"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "eframe"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "egui_kittest"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+26
View File
@@ -0,0 +1,26 @@
# Structural rules for the command-line code (RFC-0012): lint/ast-grep's rule tests, then a scan.
# Seconds, no Rust build.
name: ast-grep
on:
push:
branches: [main]
paths: ["lint/ast-grep/**", "apps/soundcraft-cli/**", "crates/automation/src/args.rs", "apps/soundcraft/src/launch_args.rs", "apps/soundcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
pull_request:
paths: ["lint/ast-grep/**", "apps/soundcraft-cli/**", "crates/automation/src/args.rs", "apps/soundcraft/src/launch_args.rs", "apps/soundcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ast-grep-${{ github.ref }}
cancel-in-progress: true
jobs:
scan:
# Tracked at @main like the other shared workflows.
uses: storytold/craft-common-actions/.github/workflows/ast-grep.yml@main
with:
config: lint/ast-grep/sgconfig.yml
paths: apps/soundcraft-cli crates/automation/src/args.rs apps/soundcraft/src/launch_args.rs apps/soundcraft/src/main.rs
+27
View File
@@ -0,0 +1,27 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
libgl1-mesa-dev libasound2-dev
+7 -30
View File
@@ -3,20 +3,19 @@
# player falls back to its silent clock when the VM has no sound device.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
schedule:
- cron: "47 3 * * *"
push:
branches: [release]
pull_request:
paths:
- "crates/playback/**"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "47 3 * * *"
workflow_dispatch:
concurrency:
@@ -28,27 +27,5 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
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.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
+61
View File
@@ -0,0 +1,61 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
+22 -42
View File
@@ -19,49 +19,29 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/soundcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/soundcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.soundcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.soundcraft" and m["command"] == "soundcraft", m["id"]
assert "--socket=pulseaudio" in m["finish-args"], "audio needs --socket=pulseaudio"
for key in ("runtime", "runtime-version", "sdk", "finish-args", "add-extensions"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+22 -427
View File
@@ -1,11 +1,3 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "SoundCraft v<version>". See docs/releasing.md.
#
# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release`
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
# other jobs sign nothing and get no secrets, so a dispatch on any branch builds and checks them
# (a dry run); the signed jobs are refused there, and so the draft release is skipped.
name: Release
on:
@@ -18,430 +10,33 @@ on:
type: string
default: ""
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
SOUNDCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets (and gates every other job).
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### SoundCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
libasound2-dev pkg-config \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/soundcraft$|metainfo|applications'
ldd target/release/soundcraft
# cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml).
ldd target/release/soundcraft | grep -q libasound
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-${{ matrix.arch }}.AppImage.zsync'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/soundcraft-$SOUNDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI (which links no ALSA) is smoke-tested under QEMU. Plugin hosting (CLAP, VST3) is
# pure-Rust bindings plus dlopen, so it builds here unchanged; cpal uses ALSA (no JACK).
runs-on: ubuntu-24.04
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
# The riscv64 twins of the linux job's -dev packages; libasound2-dev is cpal's ALSA backend.
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
pkg-config file desktop-file-utils appstream \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64
- name: Build and package (tar.gz)
env:
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- name: Check the package
run: |
file target/riscv64gc-unknown-linux-gnu/release/soundcraft | grep -q 'RISC-V'
# cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml).
riscv64-linux-gnu-readelf -d target/riscv64gc-unknown-linux-gnu/release/soundcraft | grep -q 'libasound'
tar -tzf "dist/release/soundcraft-$SOUNDCRAFT_VERSION-linux-riscv64.tar.gz" | grep -E 'bin/soundcraft$'
- uses: actions/upload-artifact@v4
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
runs-on: ubuntu-latest
timeout-minutes: 150
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
if: inputs.version != ''
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- name: Build and package in a FreeBSD VM
# v1.5.9, pinned by commit.
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
mem: 12288
# Passed through by name; never templated into the script.
envs: "SOUNDCRAFT_VERSION SOUNDCRAFT_BUILD_DATE SOUNDCRAFT_BUILD_SHA"
# alsa-lib: cpal's audio backend on FreeBSD (ALSA over OSS via alsa-plugins-oss at runtime).
prepare: |
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM.
export CARGO_TARGET_DIR=/var/tmp/soundcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-freebsd
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release
# Needs the signed jobs, which only run on the `release` branch: a branch dispatch never drafts.
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
# The shared workflow creates the draft release, which needs write access to contents.
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="SoundCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
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.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: soundcraft
app-title: SoundCraft
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+21
View File
@@ -0,0 +1,21 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+8 -66
View File
@@ -24,70 +24,12 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: soundcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: soundcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'SoundCraft'
foreach ($exe in 'soundcraft.exe', 'soundcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'soundcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "soundcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok soundcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'soundcraft.exe')) { throw 'uninstall left soundcraft.exe behind' }
Write-Output 'ok uninstalled'
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
with:
app-name: soundcraft
app-dir: SoundCraft
install-relative-path: Programs/ArtCraft/SoundCraft
install-scope: perUser
+1
View File
@@ -32,6 +32,7 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
- The audio callback never blocks, allocates in steady state, or panics.
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## Architecture (layers enforced by `cargo xtask layers`)
+12 -1
View File
@@ -22,9 +22,19 @@ are bundled.
## Fonts
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 |
@@ -45,3 +55,4 @@ and, at runtime, the operating system's own UI fonts when available (never redis
| `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 |
+4
View File
@@ -1 +1,5 @@
Read `AGENTS.md`: it is the entry point for agents in this repo.
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
before modules or other crate items; crate-level `//!` documentation may precede them.
Generated
+1591 -569
View File
File diff suppressed because it is too large Load Diff
+48 -8
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.14.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -29,22 +29,40 @@ soundcraft-ui-egui = { path = "crates/ui-egui" }
# foundation
thiserror = "2"
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4.6.11"
clap_mangen = { version = "0.3.3", features = ["env"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
# Whether an output path is one of the inputs (the CLI refuses to replace an input unless asked).
same-file = "1"
proptest = "1"
# audio
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
symphonia = { version = "0.6", default-features = false, features = ["mp1", "mp2", "mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
rustfft = "6"
cpal = "0.16"
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
zip = { version = "9", default-features = false }
cpal = "0.18"
# Which file paths stored in a session may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
egui = { version = "0.37" }
eframe = { version = "0.37", default-features = false }
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
web-time = "1.1"
egui_kittest = "0.37"
rfd = "0.17"
image = { version = "0.25", default-features = false, features = ["png"] }
@@ -52,19 +70,41 @@ image = { version = "0.25", default-features = false, features = ["png"] }
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
too_many_arguments = "deny"
type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+274 -3
View File
@@ -12,6 +12,277 @@ Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
None: every bundled asset is original to SoundCraft. Icons are drawn in code; demo audio is
synthesised in code. The egui default fonts (Ubuntu-Light, Hack, Noto Emoji, Emoji Icon Font) ship
inside the egui crate under their own open licenses (see ATTRIBUTION.md).
Original SoundCraft material: icons are drawn in code; demo audio is synthesised in code.
The egui default fonts (Ubuntu-Light, Hack, Noto Emoji, Emoji Icon Font) ship inside the egui crate
under their own open licenses (see ATTRIBUTION.md).
Default egui fonts (dependency assets)
The pinned egui 0.37 dependency embeds these faces through `default_fonts` and
`monochrome_emoji_fonts`:
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
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
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
embeds NotoEmoji-Regular.ttf, the monochrome emoji fallback used when egui's
`monochrome_emoji_fonts` feature is enabled. The font's copyright notice and the
upstream SIL Open Font License 1.1 text follow unchanged.
Copyright 2013 Google Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
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 THE
COPYRIGHT HOLDER 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.
Ubuntu Light font (dependency asset)
------------------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
embeds Ubuntu-Light.ttf as a default fallback. The font's copyright notice and the
upstream Ubuntu Font Licence 1.0 text follow unchanged.
Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
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 THE
COPYRIGHT HOLDER 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.
Hack font (dependency asset)
----------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
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.
+29 -16
View File
@@ -7,10 +7,14 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.soundcraft.png" width="128" height="128" alt="SoundCraft icon: a singing bird on a blue tile">
</p>
<h1 align="center">SoundCraft</h1>
<p align="center">
<b>Recording, editing and mixing audio; an open-source, clean-room reimplementation of Avid Pro Tools, rebuilt in pure Rust.</b>
<b>Recording, editing and mixing audio; an open-source, modern digital audio workstation (DAW), built in pure Rust.</b>
</p>
<p align="center">
@@ -25,6 +29,9 @@
<img alt="Platforms" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-0b7385?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
<img alt="Status: pre-alpha" src="https://img.shields.io/badge/status-pre--alpha-orange?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_soundcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/soundcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -107,17 +114,19 @@ where things are.
Scrubber tools; cut, copy, paste, duplicate, repeat, shift, insert silence, separate (at
selection, on grid, at transients), heal, consolidate, strip silence, nudge; fades and
crossfades in five shapes, drawn straight from the clip corners; playlists with comping lanes;
clip gain lines; edit groups; single-key Commands Focus shortcuts; undo for everything.
clip gain lines; edit groups; single-key Commands Focus shortcuts; trackpad scrolling that
follows your fingers and pinch-to-zoom around the pointer; undo for everything.
- **Mixing:** fader and pan with automation, pre/post sends, busses, aux inputs, master faders,
VCA masters, routing folders, solo modes, mute and solo groups, phase invert and trim, clip
effects, and automatic plugin delay compensation.
- **Automation:** volume, pan, mute, send and every plugin parameter; Write, Touch, Latch and
Trim passes recorded live from the faders; thin, glide, coalesce and convert to clip gain.
- **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer,
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi,
rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither,
a subtractive synth and a drum synth, all original. **CLAP plugins** load too. Any processor
runs offline as AudioSuite, and user presets save per plugin.
channel strip, room and plate reverbs, mod delay, auto pan, chorus, flanger, phaser, ring
modulator, saturator, lo-fi, rectifier, subharmonic, pitch shifter, time shift, gain, trim,
invert, DC removal, signal generator, dither, a subtractive synth, a drum synth and a sampler
(plays any WAV, AIFF, FLAC, MP3 or OGG across the keyboard), all original. **CLAP plugins**
load too. Any processor runs offline as AudioSuite, and user presets save per plugin.
- **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event
list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files,
and Audio-to-MIDI from a sung or played melody.
@@ -129,7 +138,7 @@ where things are.
pitch-preserving Elastic warping.
- **Audio files:** WAV/BWF/RF64, AIFF/AIFC and FLAC in and out; MP3, Ogg Vorbis, AAC, ALAC, CAF
and the audio of MP4/MOV movies in. Any sample rate and bit depth.
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with dither and
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with optional dither and
normalisation, reporting peak, true peak and integrated loudness (LUFS).
- **Fast:** a parallel mix engine, copy-on-write undo, and a UI that holds 120 frames per second
with seventy-odd tracks playing.
@@ -175,7 +184,7 @@ 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` | No preferences read or written and no log file (agents' test runs). |
| `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
@@ -202,10 +211,14 @@ soundcraft-cli app --port 7801 ui.screenshot '{"path":"shot.png"}'
# Give Claude a DAW.
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
@@ -250,7 +263,7 @@ 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
@@ -277,6 +290,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
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 |
@@ -366,13 +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>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float Panel, Move Panel to Group, and Close Panel; the tab group's menu adds Float Tab Group and Close Tab Group. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
+59 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness by audience with hours to ~95 %) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-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 page is
@@ -122,6 +122,39 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **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/
@@ -143,6 +176,31 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence Extract and Separate; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Clip Looping dialog; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: AudioSuite Vari-Fi speed ramps; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Auto Pan plugin; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Subharmonic plugin; readiness estimates unchanged |
| 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged |
| 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged |
| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
| 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged |
| 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged |
| 2026-10-16 | trivial | Split into Mono preserves each source channel, clip edits and saved audio; readiness estimates unchanged |
| 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) |
| 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) |
| 2026-10-13 | trivial | Progress log: sampler ping-pong phase for wide steps (#463) |
| 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) |
| 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) |
| 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) |
| 2026-10-12 | trivial | Progress log: convert option validation (#256) |
| 2026-10-12 | trivial | Progress log: timecode overflow (#248) |
| 2026-10-12 | trivial | Progress log: bar offset saturates (#249) |
| 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 |
+12
View File
@@ -6,6 +6,10 @@ edition.workspace = true
license.workspace = true
publish = false
[lib]
name = "soundcraft_cli"
path = "src/lib.rs"
[[bin]]
name = "soundcraft-cli"
path = "src/main.rs"
@@ -18,4 +22,12 @@ soundcraft-engine.workspace = true
soundcraft-automation.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-dsp.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
clap.workspace = true
same-file.workspace = true
[dev-dependencies]
clap_complete.workspace = true
clap_mangen.workspace = true
@@ -0,0 +1,42 @@
//! Writes soundcraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish,
//! PowerShell) and man pages ([`clap_mangen`]: `soundcraft-cli.1` and one per subcommand, such as
//! `soundcraft-cli-convert.1`) into a directory. The Linux and FreeBSD packages ship them
//! (`packaging/linux/package.sh`, `packaging/freebsd/package.sh`):
//!
//! ```sh
//! cargo run -p soundcraft-cli --example man-and-completions -- <out-dir>
//! ```
use std::path::Path;
use std::process::ExitCode;
use clap::ValueEnum;
use clap_complete::Shell;
fn main() -> ExitCode {
let mut args = std::env::args_os().skip(1);
let (Some(dir), None) = (args.next(), args.next()) else {
eprintln!("usage: man-and-completions <out-dir>");
return ExitCode::from(2);
};
let dir = Path::new(&dir);
match generate(dir) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {}: {e}", dir.display());
ExitCode::FAILURE
}
}
}
/// Also run by `tests/it/man_and_completions.rs`.
pub(crate) fn generate(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let mut cmd = soundcraft_cli::command();
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut cmd, "soundcraft-cli", dir)?;
}
// A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand,
// which `clap_mangen` would otherwise give pages of its own (`soundcraft-cli-help-convert.1`).
clap_mangen::generate_to(soundcraft_cli::command(), dir)
}
+72
View File
@@ -0,0 +1,72 @@
use super::args::AppArgs;
use super::error::CliError;
use super::output::print_json;
use serde_json::{Value, json};
use soundcraft_automation::Remote;
use std::io::Write;
/// Control-channel methods. Every other dotted id is an engine command.
fn is_control_method(method: &str) -> bool {
matches!(
method,
"engine.execute"
| "command"
| "engine.commands"
| "engine.parity"
| "session.inspect"
| "document.inspect"
| "ui.inspect"
| "ui.menu.list"
| "ui.menu.invoke"
| "ui.set"
| "ui.move"
| "ui.click"
| "ui.drag"
| "ui.key"
| "ui.text"
| "ui.screenshot"
| "app.quit"
)
}
/// A command id is shorthand for `engine.execute`. Documented control methods stay methods.
fn app_request(method: &str, params: Value) -> (String, Value) {
if is_control_method(method) || !method.contains('.') {
(method.to_string(), params)
} else {
("engine.execute".to_string(), json!({"command": method, "params": params}))
}
}
pub(crate) fn app(args: &AppArgs, out: &mut dyn Write) -> Result<(), CliError> {
let method = &args.method;
let params = args.json.clone().unwrap_or_else(|| json!({}));
let mut r = Remote::new(&args.control.port);
// A bare command id is shorthand for engine.execute. Control-channel methods stay methods.
let (m, p) = app_request(method, params);
let v = r.request(&m, p)?;
print_json(out, &v, args.compact);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() {
let (m, p) = app_request("engine.history", json!({}));
assert_eq!(m, "engine.execute");
assert_eq!(p["command"], "engine.history");
let (m, p) = app_request("ui.theme", json!({"mode": "light"}));
assert_eq!(m, "engine.execute");
assert_eq!(p["params"]["mode"], "light");
let (m, _) = app_request("session.new", json!({"name": "Session A"}));
assert_eq!(m, "engine.execute");
let (m, _) = app_request("track.new", json!({}));
assert_eq!(m, "engine.execute");
for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] {
assert_eq!(app_request(method, json!({})).0, method);
}
}
}
+459
View File
@@ -0,0 +1,459 @@
//! The command line, as clap types. A flag a subcommand doesn't take is a usage error (exit 2), so
//! a typo such as `--bounces` can't be silently ignored (#549), and so is a value flag without its
//! value: a following flag is never taken as the value.
use std::borrow::Cow;
use std::ffi::OsString;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use clap::builder::NonEmptyStringValueParser;
use clap::error::ErrorKind;
use clap::{ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use serde_json::{Value, json};
use soundcraft_audio_io::FileFormat;
use soundcraft_automation::ControlTarget;
use soundcraft_automation::args::{AutomationArgs, ControlClientArgs, blank_env_is_unset, control_target, long_version, name_env_vars};
/// Help text that names the extensions `list` gives, built once from
/// [`FileFormat::readable_extensions`] or [`FileFormat::writable_extensions`] so it can't drift
/// from what the code accepts.
fn with_formats(cell: &'static OnceLock<String>, before: &str, list: fn() -> Vec<&'static str>, after: &str) -> &'static str {
cell.get_or_init(|| format!("{before}({}){after}", list().join(", ")))
}
fn info_file_help() -> &'static str {
static C: OnceLock<String> = OnceLock::new();
with_formats(&C, "An audio file ", FileFormat::readable_extensions, " or a .scraft session")
}
fn read_help() -> &'static str {
static C: OnceLock<String> = OnceLock::new();
with_formats(&C, "The audio file to read ", FileFormat::readable_extensions, "")
}
fn write_help() -> &'static str {
static C: OnceLock<String> = OnceLock::new();
with_formats(&C, "The audio file to write; its extension picks the format ", FileFormat::writable_extensions, "")
}
/// soundcraft-cli: SoundCraft without a window
#[derive(Parser)]
#[command(name = "soundcraft-cli", version = long_version(), arg_required_else_help = true)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: Command,
}
#[derive(Subcommand)]
pub(crate) enum Command {
Info(InfoArgs),
Convert(ConvertArgs),
Run(RunArgs),
Script(ScriptArgs),
Commands(CommandsArgs),
Describe(DescribeArgs),
App(AppArgs),
Mcp(McpArgs),
Parity(ParityArgs),
/// List the built-in plugins
Plugins,
}
/// Print an audio file's format as JSON, or a .scraft session's summary
#[derive(Args)]
pub(crate) struct InfoArgs {
#[arg(help = info_file_help())]
pub(crate) file: PathBuf,
/// One line of JSON instead of pretty-printed (a .scraft summary is text and ignores it)
#[arg(long)]
pub(crate) compact: bool,
}
/// Convert an audio file: resample, change the bit depth, normalize
///
/// Dithers only when the output loses resolution, unless --dither or --no-dither says otherwise.
#[derive(Args)]
pub(crate) struct ConvertArgs {
#[arg(value_name = "IN", help = read_help())]
pub(crate) input: PathBuf,
#[arg(value_name = "OUT", help = write_help())]
pub(crate) output: PathBuf,
/// The output's bit depth
#[arg(long, value_enum, value_name = "16|24|32f", default_value = "24")]
pub(crate) bit_depth: BitDepthArg,
/// Resample to this sample rate
#[arg(long, value_name = "HZ")]
pub(crate) rate: Option<NonZeroU32>,
/// Normalize the peak to -0.1 dBFS
#[arg(long)]
pub(crate) normalize: bool,
/// Always dither
#[arg(long, conflicts_with = "no_dither")]
pub(crate) dither: bool,
/// Never dither
#[arg(long)]
pub(crate) no_dither: bool,
/// Allow an <OUT> that is the <IN> file, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
}
/// Where `run` and `script` start, and what they do with the result.
#[derive(Args)]
pub(crate) struct EngineArgs {
/// Open this session first
#[arg(long = "in", value_name = "S.scraft", conflicts_with = "demo")]
pub(crate) input: Option<PathBuf>,
/// Start from the demo session
#[arg(long, visible_alias = "sample")]
pub(crate) demo: bool,
/// Bounce the mix to this file at the end; without --start and --end, the whole session
#[arg(long, value_name = "OUT")]
pub(crate) bounce: Option<PathBuf>,
/// Where the bounce starts: seconds (12.5) or a time string
#[arg(long, value_name = "T", allow_negative_numbers = true, requires = "bounce", value_parser = time_arg)]
pub(crate) start: Option<TimeArg>,
/// Where the bounce ends: seconds (12.5) or a time string
#[arg(long, value_name = "T", allow_negative_numbers = true, requires = "bounce", value_parser = time_arg)]
pub(crate) end: Option<TimeArg>,
/// Save the session to this file at the end, replacing it
#[arg(long, value_name = "S.scraft")]
pub(crate) save: Option<PathBuf>,
/// Print the session as JSON at the end
#[arg(long)]
pub(crate) inspect: bool,
/// Print the --inspect JSON on one line instead of pretty-printed
#[arg(long, requires = "inspect")]
pub(crate) compact: bool,
/// Allow a --save that is the --in session, replacing the original
#[arg(long, requires = "input")]
pub(crate) in_place: bool,
}
/// Run engine commands on a new, demo or saved session
///
/// Prints each command's JSON result on one line, then bounces, saves and inspects, in that order.
/// The --inspect JSON is pretty-printed unless --compact is given.
#[derive(Args)]
pub(crate) struct RunArgs {
/// An engine command, with its JSON parameters after `=` (default {}); repeat for several, run
/// in order
#[arg(long, value_name = "ID[=JSON]", action = ArgAction::Append, value_parser = cmd_spec)]
pub(crate) cmd: Vec<CmdSpec>,
#[command(flatten)]
pub(crate) engine: EngineArgs,
}
/// Run a file of engine commands, one `ID JSON` per line
///
/// Blank lines and lines starting with # are skipped. A failing line stops the script unless
/// --keep-going is given.
#[derive(Args)]
pub(crate) struct ScriptArgs {
/// The script to run
pub(crate) file: PathBuf,
/// Report a failing line and go on with the next
#[arg(long)]
pub(crate) keep_going: bool,
#[command(flatten)]
pub(crate) engine: EngineArgs,
}
/// List the engine commands
#[derive(Args)]
pub(crate) struct CommandsArgs {
/// Only the commands whose id, or label in any case, contains this text
#[arg(value_parser = NonEmptyStringValueParser::new())]
pub(crate) filter: Option<String>,
}
/// Print one engine command's details as JSON
#[derive(Args)]
pub(crate) struct DescribeArgs {
/// The command's id, such as file.bounce_mix
#[arg(value_parser = NonEmptyStringValueParser::new())]
pub(crate) id: String,
/// One line of JSON instead of pretty-printed
#[arg(long)]
pub(crate) compact: bool,
}
/// Call a running app's control channel
///
/// A dotted engine command id is shorthand for engine.execute; the control methods
/// (session.inspect, ui.screenshot, …) stay methods. Prints the reply as JSON.
#[derive(Args)]
pub(crate) struct AppArgs {
#[command(flatten)]
pub(crate) control: ControlClientArgs,
/// A control method or engine command id
#[arg(value_parser = NonEmptyStringValueParser::new())]
pub(crate) method: String,
/// Its parameters as JSON [default: {}]
#[arg(value_parser = json_value)]
pub(crate) json: Option<Value>,
/// One line of JSON instead of pretty-printed
#[arg(long)]
pub(crate) compact: bool,
}
/// Serve MCP on stdin and stdout
///
/// The automation roots confine every file the agent's commands read (open, import, presets) and
/// write (save, bounce, export) to those folders, links followed. Each root flag may be given more
/// than once, a path inside any of its folders being allowed, and a kind left out grants none of
/// its access. The roots confine the in-process server, so they, like --demo, conflict with --connect.
#[derive(Args)]
pub(crate) struct McpArgs {
/// Serve a running app's control channel at this port or host:port instead of an in-process
/// engine; conflicts with --demo, which starts only that engine
#[arg(long, value_name = "PORT", value_parser = control_target, conflicts_with_all = ["demo", "automation_read_root", "automation_write_root"])]
pub(crate) connect: Option<ControlTarget>,
/// Start the in-process engine from the demo session
#[arg(long)]
pub(crate) demo: bool,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
}
/// Print the parity report (markdown)
#[derive(Args)]
pub(crate) struct ParityArgs {
/// Write the report to this file instead
#[arg(long, value_name = "PATH")]
pub(crate) write: Option<PathBuf>,
}
impl Command {
/// The outputs clap can't check: one that is an input file, however it is spelt, or both of
/// `--bounce` and `--save` naming one file. The paths are compared before any work starts and
/// written later, so a link swapped in meanwhile goes unnoticed: this guards against mistakes,
/// it is not a security boundary.
fn check_outputs(&self) -> Result<(), OutputError> {
match self {
Self::Convert(a) if !a.in_place && same_file(&a.input, &a.output) => {
Err(OutputError::ReplacesInput { output: "<OUT>", input: "<IN> file", path: a.output.clone() })
}
Self::Run(a) => a.engine.check_outputs(None),
Self::Script(a) => a.engine.check_outputs(Some(&a.file)),
Self::Info(_)
| Self::Convert(_)
| Self::Commands(_)
| Self::Describe(_)
| Self::App(_)
| Self::Mcp(_)
| Self::Parity(_)
| Self::Plugins => Ok(()),
}
}
}
impl ConvertArgs {
/// The file `convert` writes: <OUT>, or the file it links to when it replaces <IN> in place.
pub(crate) fn write_path(&self) -> Cow<'_, Path> {
in_place_target(self.in_place, Some(&self.input), &self.output)
}
}
impl EngineArgs {
/// The file --save writes: its path, or the file it links to when it replaces --in in place.
pub(crate) fn save_path(&self) -> Option<Cow<'_, Path>> {
self.save.as_deref().map(|save| in_place_target(self.in_place, self.input.as_deref(), save))
}
/// `script` is the script file `script` runs, which no flag lets an output replace. --in-place
/// lets only --save replace the --in session: a bounce would turn it into audio.
fn check_outputs(&self, script: Option<&Path>) -> Result<(), OutputError> {
for (flag, path) in [("--bounce", &self.bounce), ("--save", &self.save)] {
let Some(path) = path else { continue };
if script.is_some_and(|script| same_file(script, path)) {
return Err(OutputError::ReplacesScript { output: flag, path: path.clone() });
}
}
let replaces_input = |path: &Path| self.input.as_ref().is_some_and(|input| same_file(input, path));
if let Some(bounce) = &self.bounce
&& replaces_input(bounce)
{
return Err(OutputError::BounceReplacesInput(bounce.clone()));
}
if let Some(save) = &self.save
&& !self.in_place
&& replaces_input(save)
{
return Err(OutputError::ReplacesInput { output: "--save", input: "--in session", path: save.clone() });
}
match (&self.bounce, &self.save) {
(Some(bounce), Some(save)) if same_file(bounce, save) => Err(OutputError::SameOutputs(save.clone())),
_ => Ok(()),
}
}
}
/// Whether `a` and `b` name one file, however spelt (`a.wav`, `./a.wav`, a symbolic or hard link).
/// When either exists, only two regular files can be one, compared by identity (device and inode);
/// a FIFO or device is never opened, so the check can't block. When neither exists yet, they are
/// one if they would be created at the same place.
fn same_file(a: &Path, b: &Path) -> bool {
if a.exists() || b.exists() {
return a.is_file() && b.is_file() && same_file::is_same_file(a, b).unwrap_or(false);
}
matches!((destination(a), destination(b)), (Some(a), Some(b)) if a == b)
}
/// With `in_place`, an `output` that is a symbolic link to `input` resolved to the file it names.
/// A save writes a new file and renames it over its path, which would replace the link and leave
/// the input as it was: through the resolved path, `convert` and `--save` both replace the input.
fn in_place_target<'a>(in_place: bool, input: Option<&Path>, output: &'a Path) -> Cow<'a, Path> {
let link = output.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink());
if in_place
&& link
&& input.is_some_and(|input| same_file(input, output))
&& let Ok(target) = output.canonicalize()
{
return Cow::Owned(target);
}
Cow::Borrowed(output)
}
/// Where a file that doesn't exist yet would be created: its folder resolved, links followed.
fn destination(path: &Path) -> Option<PathBuf> {
let folder = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
Some(folder.canonicalize().ok()?.join(path.file_name()?))
}
/// An output [`Command::check_outputs`] refuses: `parse` prints it as a usage error (exit 2).
#[derive(Debug, thiserror::Error)]
pub(crate) enum OutputError {
#[error("{output} {} is the {input}, which it would replace: choose another file, or pass --in-place", .path.display())]
ReplacesInput { output: &'static str, input: &'static str, path: PathBuf },
#[error("--bounce {} is the --in session, which it would replace with audio: choose another file", .0.display())]
BounceReplacesInput(PathBuf),
#[error("{output} {} is the script file, which it would replace: choose another file", .path.display())]
ReplacesScript { output: &'static str, path: PathBuf },
#[error("--bounce and --save both name {}, so the save would replace the bounce: choose another file for one of them", .0.display())]
SameOutputs(PathBuf),
}
impl OutputError {
/// Two arguments that may not name one file: clap's conflict kind.
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
cmd.error(ErrorKind::ArgumentConflict, self)
}
}
/// The bit depths `convert` writes. `32` is the same as `32f`.
#[derive(Clone, Copy, ValueEnum)]
pub(crate) enum BitDepthArg {
#[value(name = "16")]
Int16,
#[value(name = "24")]
Int24,
#[value(name = "32f", alias = "32")]
Float32,
}
impl From<BitDepthArg> for soundcraft_audio_io::BitDepth {
fn from(depth: BitDepthArg) -> Self {
match depth {
BitDepthArg::Int16 => Self::Int16,
BitDepthArg::Int24 => Self::Int24,
BitDepthArg::Float32 => Self::Float32,
}
}
}
/// A bounce position: seconds, or a time string the engine reads.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum TimeArg {
Seconds(f64),
Text(String),
}
impl TimeArg {
/// The `start` / `end` parameter of `file.bounce_mix`.
pub(crate) fn to_json(&self) -> Value {
match self {
Self::Seconds(s) => json!({"seconds": s}),
Self::Text(t) => json!(t),
}
}
}
/// One `--cmd ID[=JSON]`: the command id and its parameters (`{}` when none are given).
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct CmdSpec {
pub(crate) id: String,
pub(crate) params: Value,
}
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
pub(crate) enum ValueError {
#[error("expected seconds (12.5) or a time string, not an empty value")]
EmptyTime,
#[error("expected a finite number of seconds")]
NotFinite,
#[error("expected a command id, optionally followed by `=` and JSON parameters")]
EmptyCommandId,
#[error("bad JSON: {0}")]
Json(#[from] serde_json::Error),
}
fn time_arg(s: &str) -> Result<TimeArg, ValueError> {
if s.is_empty() {
return Err(ValueError::EmptyTime);
}
match s.parse::<f64>() {
// JSON has no NaN or infinity: serde_json would send the engine `null`.
Ok(secs) if !secs.is_finite() => Err(ValueError::NotFinite),
Ok(secs) => Ok(TimeArg::Seconds(secs)),
Err(_) => Ok(TimeArg::Text(s.to_owned())),
}
}
fn json_value(s: &str) -> Result<Value, ValueError> {
Ok(serde_json::from_str(s)?)
}
/// `ID` or `ID=JSON`; malformed JSON is an error, not `{}`.
fn cmd_spec(s: &str) -> Result<CmdSpec, ValueError> {
let (id, params) = match s.split_once('=') {
Some((id, p)) => (id, json_value(p)?),
None => (s, json!({})),
};
if id.is_empty() {
return Err(ValueError::EmptyCommandId);
}
Ok(CmdSpec { id: id.to_owned(), params })
}
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a folder need
/// not be valid Unicode, text must. A flag wins over its environment variable, unless that is
/// empty, and a bad value from one is a usage error naming the variable. A bare `--help` or `-h`
/// is help even after a mistake on the same line (`run --bounce --help`): clap reports the mistake
/// first. An output that would replace an input is refused here too
/// ([`Command::check_outputs`]), so nothing has run when it is reported.
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
let mut cmd = Cli::command().mut_subcommands(|sub| sub.mut_args(blank_env_is_unset));
let argv = std::iter::once(OsString::from("soundcraft-cli")).chain(args.iter().cloned());
let matches = match cmd.try_get_matches_from_mut(argv) {
Err(e) if e.kind() != ErrorKind::DisplayHelp => {
let Some(flag) = args.iter().take_while(|a| *a != "--").find(|a| *a == "--help" || *a == "-h") else {
return Err(name_env_vars(e, &cmd, args));
};
let help = [OsString::from("soundcraft-cli"), args.first().cloned().unwrap_or_default(), flag.clone()];
return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e));
}
matches => matches?,
};
let cli = Cli::from_arg_matches(&matches)?;
if let Err(e) = cli.command.check_outputs() {
return Err(match matches.subcommand_name().and_then(|name| cmd.find_subcommand_mut(name)) {
Some(sub) => e.into_clap(sub),
None => e.into_clap(&mut cmd),
});
}
Ok(cli)
}
+50
View File
@@ -0,0 +1,50 @@
use super::args::{CommandsArgs, DescribeArgs, ParityArgs};
use super::error::CliError;
use super::output::print_json;
use soundcraft_engine::Engine;
use std::io::Write;
pub(crate) fn commands(args: &CommandsArgs, out: &mut dyn Write) {
let filter = args.filter.clone().unwrap_or_default();
let e = Engine::default();
for c in soundcraft_engine::command_specs() {
if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) {
continue;
}
let i = c.info(&e);
outln!(out, "{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
}
}
pub(crate) fn describe(args: &DescribeArgs, out: &mut dyn Write) -> Result<(), CliError> {
let id = &args.id;
let c = soundcraft_engine::find_command(id).ok_or_else(|| CliError::UnknownCommand(id.clone()))?;
print_json(out, &c.info(&Engine::default()), args.compact);
Ok(())
}
pub(crate) fn parity(args: &ParityArgs, out: &mut dyn Write) -> Result<(), CliError> {
let p = soundcraft_engine::catalog::parity_json();
let mut md = String::new();
md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`).\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",
p["implemented"], p["total"], p["percent"]
));
for m in p["per_menu"].as_array().cloned().unwrap_or_default() {
md.push_str(&format!("| {} | {} | {} |\n", m["menu"].as_str().unwrap_or(""), m["implemented"], m["total"]));
}
md.push_str("\n## Not yet implemented\n\n");
for m in p["missing"].as_array().cloned().unwrap_or_default() {
md.push_str(&format!("- {}\n", m.as_str().unwrap_or("")));
}
if let Some(path) = &args.write {
std::fs::write(path, &md).map_err(|source| CliError::File { path: path.clone(), source })?;
outln!(out, "wrote {}: {}% ({}/{})", path.display(), p["percent"], p["implemented"], p["total"]);
} else {
outln!(out, "{md}");
}
Ok(())
}
+105
View File
@@ -0,0 +1,105 @@
use super::args::ConvertArgs;
use super::error::{CliError, ConvertError};
use std::io::Write;
use std::num::NonZeroU32;
pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), CliError> {
let (input, output) = (&args.input, &args.output);
let rate = args.rate.map(NonZeroU32::get);
let bit_depth = args.bit_depth.into();
let dither_flag = match (args.dither, args.no_dither) {
(true, _) => Some(true),
(_, true) => Some(false),
_ => None,
};
let bytes = std::fs::read(input).map_err(|source| CliError::File { path: input.clone(), source })?;
let ext = input.extension().and_then(std::ffi::OsStr::to_str);
let (info, mut buf) = soundcraft_audio_io::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?;
if let Some(rate) = rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
buf.sample_rate = rate;
}
let normalize = args.normalize;
if normalize {
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
}
let processed = rate.is_some_and(|r| r != info.sample_rate) || normalize;
let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed));
// Lossy only for the message: an extension that is not valid Unicode names no format.
let out_ext = output.extension().map_or_else(|| "wav".into(), |e| e.to_string_lossy().to_ascii_lowercase());
let Some(format) = soundcraft_audio_io::FileFormat::from_write_extension(&out_ext) else {
return Err(ConvertError::OutputFormat { output: output.clone(), ext: out_ext }.into());
};
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
let bytes = soundcraft_audio_io::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?;
write_replacing(&args.write_path(), &bytes).map_err(|source| CliError::File { path: output.clone(), source })?;
outln!(out, "wrote {} ({} ch, {} Hz, {:.2} s)", output.display(), buf.num_channels(), buf.sample_rate, buf.duration_secs());
Ok(())
}
/// Writes `bytes` to a new file next to `target`, flushes it and renames it over `target`, so `target`
/// (the input, for --in-place) holds either its old or its new contents. The new file is removed when
/// writing or renaming fails.
fn write_replacing(target: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
let name = target.file_name().map_or_else(|| "output".into(), |n| n.to_string_lossy().into_owned());
let tmp = target.with_file_name(format!(".{name}.{}.part", std::process::id()));
let written = (|| {
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp)?;
file.write_all(bytes)?;
file.sync_all()?;
std::fs::rename(&tmp, target)
})();
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
/// Whether `convert` dithers when not told: only when writing integer samples that cannot hold the
/// source exactly, i.e. the output has fewer bits than the source, the source is float or lossy, or
/// resampling/normalizing produced new sample values. A 16-bit file written as 16 or 24-bit, or
/// anything written as 32-bit float, is copied sample for sample (digital silence stays silent).
fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_audio_io::BitDepth, processed: bool) -> bool {
use soundcraft_audio_io::{BitDepth, SampleFormat};
let target_bits = match target {
BitDepth::Float32 => return false,
BitDepth::Int16 => 16,
BitDepth::Int24 => 24,
BitDepth::Int32 => 32,
};
if processed {
return true;
}
match source {
SampleFormat::Int8 => target_bits < 8,
SampleFormat::Int16 => target_bits < 16,
SampleFormat::Int24 => target_bits < 24,
// Decoded through f32, which holds 24 bits: a 32-bit integer source has already lost
// resolution, so treat it like float.
SampleFormat::Int32 | SampleFormat::Float32 | SampleFormat::Float64 | SampleFormat::Compressed => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convert_dithers_only_when_resolution_is_lost() {
use soundcraft_audio_io::{BitDepth, SampleFormat};
// Same depth or wider: the samples fit exactly.
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int16, false));
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int24, false));
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Int24, false));
assert!(!auto_dither(SampleFormat::Int8, BitDepth::Int16, false));
// Float output never dithers.
assert!(!auto_dither(SampleFormat::Float32, BitDepth::Float32, true));
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Float32, false));
// Fewer bits, a float or lossy source, or processed samples: dither.
assert!(auto_dither(SampleFormat::Int24, BitDepth::Int16, false));
assert!(auto_dither(SampleFormat::Float32, BitDepth::Int24, false));
assert!(auto_dither(SampleFormat::Compressed, BitDepth::Int16, false));
assert!(auto_dither(SampleFormat::Int32, BitDepth::Int24, false));
assert!(auto_dither(SampleFormat::Int16, BitDepth::Int16, true));
}
}
+49
View File
@@ -0,0 +1,49 @@
use std::path::{Path, PathBuf};
use soundcraft_audio_io::AudioError;
use soundcraft_automation::RemoteError;
use soundcraft_engine::EngineError;
use soundcraft_engine::file_access::RootError;
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
#[derive(Debug, thiserror::Error)]
pub(crate) enum CliError {
#[error(transparent)]
Convert(#[from] ConvertError),
#[error(transparent)]
Engine(#[from] EngineError),
#[error("unknown command `{0}`")]
UnknownCommand(String),
#[error("{id}: {source}")]
Command { id: String, source: EngineError },
#[error("{}:{line}: bad JSON: {source}", path.display())]
ScriptJson { path: PathBuf, line: usize, source: serde_json::Error },
#[error("{}:{line}: {id}: {source}", path.display())]
ScriptLine { path: PathBuf, line: usize, id: String, source: EngineError },
#[error("{}: {source}", path.display())]
File { path: PathBuf, source: std::io::Error },
#[error("{}: {source}", path.display())]
Audio { path: PathBuf, source: AudioError },
/// A `--bounce` or `--save` path: the engine's commands take them in JSON, which holds only
/// text.
#[error("{}: the path is not valid Unicode", .0.display())]
NotUnicode(PathBuf),
#[error(transparent)]
Remote(#[from] RemoteError),
#[error(transparent)]
Roots(#[from] RootError),
#[error(transparent)]
Serve(std::io::Error),
}
/// A path as the text the engine's commands take.
pub(crate) fn unicode(path: &Path) -> Result<&str, CliError> {
path.to_str().ok_or_else(|| CliError::NotUnicode(path.to_owned()))
}
/// A `convert` option or output the tool refuses.
#[derive(Debug, PartialEq, thiserror::Error)]
pub(crate) enum ConvertError {
#[error("{}: cannot write `.{ext}` files (supported: {})", output.display(), soundcraft_audio_io::FileFormat::writable_extensions().join(", "))]
OutputFormat { output: PathBuf, ext: String },
}
+27
View File
@@ -0,0 +1,27 @@
use super::args::InfoArgs;
use super::error::CliError;
use super::output::print_json;
use super::session::warn_missing;
use serde_json::json;
use soundcraft_engine::Engine;
use std::io::Write;
pub(crate) fn info(args: &InfoArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
let path = &args.file;
if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("scraft")) {
let mut e = Engine::default();
let missing = soundcraft_engine::io::open_session(&mut e, path)?;
warn_missing(err, &missing);
outln!(out, "{}", soundcraft_engine::inspect::session_text(&e));
return Ok(());
}
let bytes = std::fs::read(path).map_err(|source| CliError::File { path: path.clone(), source })?;
let ext = path.extension().and_then(std::ffi::OsStr::to_str);
let i = soundcraft_audio_io::probe(&bytes, ext).map_err(|source| CliError::Audio { path: path.clone(), source })?;
print_json(
out,
&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf}),
args.compact,
);
Ok(())
}
+82
View File
@@ -0,0 +1,82 @@
//! `soundcraft-cli`: SoundCraft without a window. `run` is the whole program, so it can be tested
//! in-process as well as through the binary. `soundcraft-cli --help` lists the subcommands.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[macro_use]
mod output;
mod app;
mod args;
mod catalog;
mod convert;
mod error;
mod info;
mod mcp;
mod session;
use std::ffi::OsString;
use std::io::Write;
use clap::CommandFactory;
use app::app;
use args::{Cli, Command, parse};
use catalog::{commands, describe, parity};
use convert::convert;
use error::CliError;
use info::info;
use mcp::mcp;
use session::{run_cmds, script};
/// The command line's clap definition.
pub fn command() -> clap::Command {
Cli::command()
}
/// Run the CLI on its arguments (without the program name), writing results to `out` and
/// diagnostics to `err`. Returns the process exit code: 0 on success, 1 on failure, 2 for a usage
/// error. Help and the version go to `out`, exit 0. Plugin hosts are shut down by the caller once
/// the process is done with every engine (see `main`).
pub fn run<I, T>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
// Help and the version (`help`, `-h`, `<subcommand> --help`, `-V`) come back as errors too.
let cli = match parse(&args) {
Ok(cli) => cli,
Err(e) => {
let to: &mut dyn Write = if e.use_stderr() { err } else { out };
let _ = write!(to, "{}", e.render());
return e.exit_code();
}
};
match dispatch(cli.command, out, err) {
Ok(()) => 0,
Err(e) => {
outln!(err, "error: {e}");
1
}
}
}
fn dispatch(command: Command, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
match command {
Command::Info(a) => info(&a, out, err)?,
Command::Convert(a) => convert(&a, out)?,
Command::Run(a) => run_cmds(&a, out, err)?,
Command::Script(a) => script(&a, out, err)?,
Command::Commands(a) => commands(&a, out),
Command::Describe(a) => describe(&a, out)?,
Command::App(a) => app(&a, out)?,
Command::Mcp(a) => mcp(&a, err)?,
Command::Parity(a) => parity(&a, out)?,
Command::Plugins => {
for p in soundcraft_dsp::plugins() {
outln!(out, "{:<20} {:<22} {:?}{}", p.id, p.name, p.category, if p.is_instrument { " (instrument)" } else { "" });
}
}
}
Ok(())
}
+8 -406
View File
@@ -1,410 +1,12 @@
//! `soundcraft-cli`: SoundCraft without a window.
//!
//! ```text
//! soundcraft-cli info FILE audio file or .scraft summary
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize]
//! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect]
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT] (one `ID JSON` per line)
//! soundcraft-cli commands [FILTER] list commands
//! soundcraft-cli describe ID one command's details
//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel
//! soundcraft-cli mcp [--connect PORT] [--demo] MCP server on stdio
//! soundcraft-cli parity [--write PATH] parity report (markdown)
//! soundcraft-cli plugins list built-in plugins
//! ```
//! `soundcraft-cli`: SoundCraft without a window. See the library crate for the commands.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use serde_json::{Value, json};
use soundcraft_automation::{Backend, Headless, Remote, mcp::Server};
use soundcraft_engine::Engine;
use std::io::Write;
use std::process::ExitCode;
/// Print without panicking on a closed pipe.
macro_rules! outln {
($($t:tt)*) => {{
let mut o = std::io::stdout().lock();
if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; }
}};
}
fn arg_value(args: &[String], flag: &str) -> Option<String> {
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned()
}
fn fail(msg: impl std::fmt::Display) -> ExitCode {
eprintln!("soundcraft-cli: {msg}");
ExitCode::FAILURE
}
/// The usage block of the module doc comment, up to its closing code fence.
fn help_text() -> String {
include_str!("main.rs")
.lines()
.skip(3)
.take_while(|l| l.trim_end() != "//! ```")
.map(|l| l.trim_start_matches("//! "))
.collect::<Vec<_>>()
.join("\n")
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(cmd) = args.first().cloned() else {
eprintln!("usage: soundcraft-cli <info|convert|run|script|commands|describe|app|mcp|parity|plugins> … (see --help)");
return ExitCode::FAILURE;
};
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
let code = match cmd.as_str() {
"--version" | "-V" | "version" => {
outln!("SoundCraft CLI {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
"--help" | "-h" | "help" => {
outln!("{}", help_text());
ExitCode::SUCCESS
}
"info" => info(&rest),
"convert" => convert(&rest),
"run" => run(&rest),
"script" => script(&rest),
"commands" => commands(&rest),
"describe" => describe(&rest),
"app" => app(&rest),
"mcp" => mcp(&rest),
"parity" => parity(&rest),
"plugins" => {
for p in soundcraft_dsp::plugins() {
outln!("{:<20} {:<22} {:?}{}", p.id, p.name, p.category, if p.is_instrument { " (instrument)" } else { "" });
}
ExitCode::SUCCESS
}
other => fail(format!("unknown subcommand `{other}`")),
};
// The subcommand's engine and its plugin instances are gone by now.
fn main() {
// `args_os`, not `args`: a non-Unicode argument must reach clap, not panic before anything is printed.
let code = soundcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
// The subcommand's engine and its plugin instances are gone by now. This is process teardown,
// so it lives here and not in `run`, which tests call many times in one process.
soundcraft_engine::shutdown_plugin_hosts();
code
}
fn info(args: &[String]) -> ExitCode {
let Some(path) = args.first() else { return fail("info FILE") };
if path.ends_with(".scraft") {
let mut e = Engine::default();
match soundcraft_engine::io::open_session(&mut e, path) {
Ok(missing) => warn_missing(&missing),
Err(err) => return fail(err),
}
outln!("{}", soundcraft_engine::inspect::session_text(&e));
return ExitCode::SUCCESS;
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => return fail(format!("{path}: {e}")),
};
let ext = std::path::Path::new(path).extension().and_then(|x| x.to_str());
match soundcraft_audio_io::probe(&bytes, ext) {
Ok(i) => {
outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default());
ExitCode::SUCCESS
}
Err(e) => fail(format!("{path}: {e}")),
}
}
fn convert(args: &[String]) -> ExitCode {
let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") };
let bytes = match std::fs::read(input) {
Ok(b) => b,
Err(e) => return fail(format!("{input}: {e}")),
};
let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str());
let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
Ok(x) => x,
Err(e) => return fail(format!("{input}: {e}")),
};
if let Some(rate) = arg_value(args, "--rate").and_then(|r| r.parse::<u32>().ok()) {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
buf.sample_rate = rate;
}
if args.iter().any(|a| a == "--normalize") {
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
}
let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
};
let bit_depth = match arg_value(args, "--bit-depth").as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
_ => soundcraft_audio_io::BitDepth::Int24,
};
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None };
match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) {
Ok(()) => {
outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs());
ExitCode::SUCCESS
}
Err(e) => fail(e),
}
}
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
fn missing_warnings(missing: &[String]) -> Vec<String> {
missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
}
fn warn_missing(missing: &[String]) {
for line in missing_warnings(missing) {
eprintln!("{line}");
}
}
fn load_engine(args: &[String]) -> Result<Engine, String> {
if let Some(p) = arg_value(args, "--in") {
let mut e = Engine::default();
let missing = soundcraft_engine::io::open_session(&mut e, &p).map_err(|e| e.to_string())?;
warn_missing(&missing);
Ok(e)
} else if args.iter().any(|a| a == "--demo" || a == "--sample") {
Ok(soundcraft_engine::demo::demo_engine())
} else {
Ok(Engine::default())
}
}
fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
if let Some(out) = arg_value(args, "--bounce") {
let mut p = json!({"path": out});
if let Some(s) = arg_value(args, "--start") {
p["start"] = time_arg(&s);
}
if let Some(s) = arg_value(args, "--end") {
p["end"] = time_arg(&s);
}
match e.execute("file.bounce_mix", &p) {
Ok(v) => outln!("{v}"),
Err(err) => return fail(err),
}
}
if let Some(path) = arg_value(args, "--save") {
match e.execute("session.save", &json!({"path": path})) {
Ok(v) => outln!("{v}"),
Err(err) => return fail(err),
}
}
if args.iter().any(|a| a == "--inspect") {
outln!("{}", serde_json::to_string_pretty(&soundcraft_engine::inspect::session(e, false)).unwrap_or_default());
}
ExitCode::SUCCESS
}
/// `12.5` → seconds; anything else is passed through as a time string.
fn time_arg(s: &str) -> Value {
s.parse::<f64>().map_or_else(|_| json!(s), |f| json!({"seconds": f}))
}
/// `ID` or `ID=JSON` → command id and params; malformed JSON is an error, not `{}`.
fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> {
match spec.split_once('=') {
Some((id, p)) => serde_json::from_str::<Value>(p).map(|v| (id.to_string(), v)).map_err(|err| format!("{id}: bad JSON: {err}")),
None => Ok((spec.to_string(), json!({}))),
}
}
fn run(args: &[String]) -> ExitCode {
let mut e = match load_engine(args) {
Ok(e) => e,
Err(err) => return fail(err),
};
let mut i = 0;
while i < args.len() {
if args.get(i).is_some_and(|a| a == "--cmd")
&& let Some(spec) = args.get(i + 1)
{
let (id, params) = match parse_cmd_spec(spec) {
Ok(v) => v,
Err(err) => return fail(err),
};
match e.execute(&id, &params) {
Ok(v) => outln!("{id}: {v}"),
Err(err) => return fail(format!("{id}: {err}")),
}
i += 1;
}
i += 1;
}
finish(&mut e, args)
}
fn script(args: &[String]) -> ExitCode {
let Some(path) = args.first() else { return fail("script FILE") };
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => return fail(format!("{path}: {e}")),
};
let mut e = match load_engine(args) {
Ok(e) => e,
Err(err) => return fail(err),
};
let keep = args.iter().any(|a| a == "--keep-going");
for (n, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (id, p) = line.split_once(char::is_whitespace).unwrap_or((line, "{}"));
let params = match serde_json::from_str::<Value>(p.trim()) {
Ok(v) => v,
Err(err) => return fail(format!("{path}:{}: bad JSON: {err}", n + 1)),
};
match e.execute(id, &params) {
Ok(v) => outln!("{id}: {v}"),
Err(err) => {
eprintln!("{path}:{}: {id}: {err}", n + 1);
if !keep {
return ExitCode::FAILURE;
}
}
}
}
finish(&mut e, args)
}
fn commands(args: &[String]) -> ExitCode {
let filter = args.first().cloned().unwrap_or_default();
let e = Engine::default();
for c in soundcraft_engine::command_specs() {
if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) {
continue;
}
let i = c.info(&e);
outln!("{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
}
ExitCode::SUCCESS
}
fn describe(args: &[String]) -> ExitCode {
let Some(id) = args.first() else { return fail("describe ID") };
match soundcraft_engine::find_command(id) {
Some(c) => {
outln!("{}", serde_json::to_string_pretty(&c.info(&Engine::default())).unwrap_or_default());
ExitCode::SUCCESS
}
None => fail(format!("unknown command `{id}`")),
}
}
fn app(args: &[String]) -> ExitCode {
let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into());
let positional: Vec<&String> = args
.iter()
.enumerate()
.filter(|(i, a)| !(a.starts_with("--") || (*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port"))))
.map(|(_, a)| a)
.collect();
let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") };
let params = match positional.get(1) {
Some(p) => match serde_json::from_str::<Value>(p) {
Ok(v) => v,
Err(err) => return fail(format!("{method}: bad JSON: {err}")),
},
None => json!({}),
};
let mut r = Remote::new(&port);
// A bare command id is shorthand for engine.execute.
let (m, p) = if method.contains('.')
&& !method.starts_with("ui.")
&& !method.starts_with("engine.")
&& !method.starts_with("session.")
&& !method.starts_with("app.")
{
("engine.execute".to_string(), json!({"command": method, "params": params}))
} else {
(method.to_string(), params)
};
match r.call(&m, p) {
Ok(v) => {
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
ExitCode::SUCCESS
}
Err(e) => fail(e),
}
}
fn mcp(args: &[String]) -> ExitCode {
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let r = if let Some(port) = arg_value(args, "--connect") {
let mut s = Server::new(Remote::new(&port));
s.serve(stdin.lock(), stdout.lock())
} else {
let e = if args.iter().any(|a| a == "--demo") { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let mut s = Server::new(Headless::new(e));
s.serve(stdin.lock(), stdout.lock())
};
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => fail(e),
}
}
fn parity(args: &[String]) -> ExitCode {
let p = soundcraft_engine::catalog::parity_json();
let mut md = String::new();
md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`).\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",
p["implemented"], p["total"], p["percent"]
));
for m in p["per_menu"].as_array().cloned().unwrap_or_default() {
md.push_str(&format!("| {} | {} | {} |\n", m["menu"].as_str().unwrap_or(""), m["implemented"], m["total"]));
}
md.push_str("\n## Not yet implemented\n\n");
for m in p["missing"].as_array().cloned().unwrap_or_default() {
md.push_str(&format!("- {}\n", m.as_str().unwrap_or("")));
}
if let Some(path) = arg_value(args, "--write") {
if let Err(e) = std::fs::write(&path, &md) {
return fail(format!("{path}: {e}"));
}
outln!("wrote {path}: {}% ({}/{})", p["percent"], p["implemented"], p["total"]);
} else {
outln!("{md}");
}
ExitCode::SUCCESS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_media_gets_one_warning_each() {
assert!(missing_warnings(&[]).is_empty());
let w = missing_warnings(&["a.wav".to_string(), "b.wav: decode failed".to_string()]);
assert_eq!(w, vec!["warning: missing media: a.wav", "warning: missing media: b.wav: decode failed"]);
}
#[test]
fn cmd_spec_parses_id_and_json() {
assert_eq!(parse_cmd_spec("a.b").unwrap(), ("a.b".to_string(), json!({})));
assert_eq!(parse_cmd_spec("a.b={\"x\":1}").unwrap(), ("a.b".to_string(), json!({"x": 1})));
}
#[test]
fn cmd_spec_rejects_truncated_json() {
let err = parse_cmd_spec("a.b={\"x\":").unwrap_err();
assert!(err.starts_with("a.b: bad JSON"), "{err}");
}
#[test]
fn help_text_is_only_the_usage_block() {
let help = help_text();
assert!(help.starts_with("soundcraft-cli info FILE"));
assert!(help.ends_with("soundcraft-cli plugins list built-in plugins"));
assert!(!help.contains("```"));
assert!(!help.contains("#![") && !help.contains("use serde_json"));
}
std::process::exit(code);
}
+46
View File
@@ -0,0 +1,46 @@
use super::args::McpArgs;
use super::error::CliError;
use soundcraft_automation::{ControlTarget, Headless, Remote, mcp::Server};
use soundcraft_engine::Engine;
use soundcraft_engine::file_access::{self, AutomationRoots};
use std::io::Write;
/// What `mcp` serves.
struct Setup {
connect: Option<ControlTarget>,
demo: bool,
roots: Option<std::sync::Arc<AutomationRoots>>,
}
/// Open the roots before the server starts: a folder that can't be opened must not leave the
/// server running unconfined.
fn setup(args: &McpArgs) -> Result<Setup, CliError> {
let read: Vec<&std::path::Path> = args.automation.automation_read_root.iter().map(std::path::PathBuf::as_path).collect();
let write: Vec<&std::path::Path> = args.automation.automation_write_root.iter().map(std::path::PathBuf::as_path).collect();
let roots = AutomationRoots::new(&read, &write)?;
Ok(Setup { connect: args.connect.clone(), demo: args.demo, roots })
}
/// Serves on the process's real stdin and stdout; only the notes go to `err`.
pub(crate) fn mcp(args: &McpArgs, err: &mut dyn Write) -> Result<(), CliError> {
let Setup { connect, demo, roots } = setup(args)?;
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let served = if let Some(target) = connect {
let mut s = Server::new(Remote::new(&target));
s.serve(stdin.lock(), stdout.lock())
} else {
// `--demo` is the operator's own choice, made before the roots come into force.
let e = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let mut h = Headless::new(e);
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
outln!(err, "soundcraft-cli: files confined: {}", roots.describe());
h = h.with_automation_roots(Some(roots));
}
let mut s = Server::new(h);
s.serve(stdin.lock(), stdout.lock())
};
served.map_err(CliError::Serve)
}
+32
View File
@@ -0,0 +1,32 @@
/// Print to a writer without panicking on a closed pipe. A write error must not skip later work
/// such as `--bounce` or `--save`, and must not by itself report success.
macro_rules! outln {
($w:expr, $($t:tt)*) => {{
let _ = writeln!($w, $($t)*);
}};
}
/// Print `value` as JSON: pretty-printed, or on one line when `compact`. Every subcommand that
/// prints a JSON document goes through here, so `--compact` means the same thing everywhere.
pub(crate) fn print_json(out: &mut dyn std::io::Write, value: &impl serde::Serialize, compact: bool) {
let text = if compact { serde_json::to_string(value) } else { serde_json::to_string_pretty(value) };
outln!(out, "{}", text.unwrap_or_default());
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn compact_is_one_line_and_pretty_is_the_same_value_over_several() {
let v = json!({"a": [1, 2], "b": {"c": "d"}});
let (mut pretty, mut compact) = (Vec::new(), Vec::new());
print_json(&mut pretty, &v, false);
print_json(&mut compact, &v, true);
let (pretty, compact) = (String::from_utf8(pretty).unwrap_or_default(), String::from_utf8(compact).unwrap_or_default());
assert!(pretty.trim_end().lines().count() > 1);
assert_eq!(compact, "{\"a\":[1,2],\"b\":{\"c\":\"d\"}}\n");
assert_eq!(serde_json::from_str::<serde_json::Value>(&pretty).ok(), Some(v));
}
}
+111
View File
@@ -0,0 +1,111 @@
use super::args::{CmdSpec, EngineArgs, RunArgs, ScriptArgs};
use super::error::{CliError, unicode};
use super::output::print_json;
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use std::io::Write;
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
pub(crate) fn warn_missing(err: &mut dyn Write, missing: &[String]) {
for m in missing {
outln!(err, "warning: missing media: {m}");
}
}
/// The outputs go to the engine as JSON text, so a non-Unicode one is refused before any command runs.
fn load_engine(args: &EngineArgs, err: &mut dyn Write) -> Result<Engine, CliError> {
if let Some(bounce) = &args.bounce {
unicode(bounce)?;
}
if let Some(save) = args.save_path() {
unicode(&save)?;
}
if let Some(p) = &args.input {
let mut e = Engine::default();
let missing = soundcraft_engine::io::open_session(&mut e, p)?;
warn_missing(err, &missing);
Ok(e)
} else if args.demo {
Ok(soundcraft_engine::demo::demo_engine())
} else {
Ok(Engine::default())
}
}
fn finish(e: &mut Engine, args: &EngineArgs, out: &mut dyn Write) -> Result<(), CliError> {
if let Some(path) = &args.bounce {
let mut p = json!({"path": unicode(path)?});
if let Some(s) = &args.start {
p["start"] = s.to_json();
}
if let Some(s) = &args.end {
p["end"] = s.to_json();
}
// No range given: bounce the whole session, not whatever edit selection the commands left.
if p.get("start").is_none() && p.get("end").is_none() {
p["start"] = json!(0);
p["end"] = json!(e.session().content_end().max(1));
}
let v = e.execute("file.bounce_mix", &p)?;
outln!(out, "{v}");
}
if let Some(path) = args.save_path() {
let v = e.execute("session.save", &json!({"path": unicode(&path)?, "replace": true}))?;
outln!(out, "{v}");
}
if args.inspect {
print_json(out, &soundcraft_engine::inspect::session(e, false), args.compact);
}
Ok(())
}
pub(crate) fn run_cmds(args: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
let mut e = load_engine(&args.engine, err)?;
for CmdSpec { id, params } in &args.cmd {
match e.execute(id, params) {
Ok(v) => outln!(out, "{id}: {v}"),
Err(source) => return Err(CliError::Command { id: id.clone(), source }),
}
}
finish(&mut e, &args.engine, out)
}
pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
let path = &args.file;
let text = std::fs::read_to_string(path).map_err(|source| CliError::File { path: path.clone(), source })?;
let mut e = load_engine(&args.engine, err)?;
let keep = args.keep_going;
for (n, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (id, p) = line.split_once(char::is_whitespace).unwrap_or((line, "{}"));
let params = serde_json::from_str::<Value>(p.trim()).map_err(|source| CliError::ScriptJson { path: path.clone(), line: n + 1, source })?;
match e.execute(id, &params) {
Ok(v) => outln!(out, "{id}: {v}"),
Err(source) => {
let failure = CliError::ScriptLine { path: path.clone(), line: n + 1, id: id.to_owned(), source };
if !keep {
return Err(failure);
}
outln!(err, "{failure}");
}
}
}
finish(&mut e, &args.engine, out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_media_gets_one_warning_each() {
let mut err = Vec::new();
warn_missing(&mut err, &[]);
assert!(err.is_empty());
warn_missing(&mut err, &["a.wav".to_string(), "b.wav: decode failed".to_string()]);
assert_eq!(String::from_utf8(err).unwrap(), "warning: missing media: a.wav\nwarning: missing media: b.wav: decode failed\n");
}
}
+95
View File
@@ -0,0 +1,95 @@
//! `soundcraft-cli convert` dithers only when the output loses resolution (#548), run as the real binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use std::process::Command;
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A 16-bit mono WAV of `samples`, written without dither so the file holds exactly these values.
fn write_wav16(path: &Path, samples: &[i16]) {
let mut buf = AudioBuffer::new(48_000, 1, samples.len());
for (d, s) in buf.channels[0].iter_mut().zip(samples) {
*d = f32::from(*s) / 32768.0;
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
}
fn convert(input: &Path, output: &Path, extra: &[&str]) {
let o = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"))
.env("SOUNDCRAFT_NO_PREFS", "1")
.arg("convert")
.arg(input)
.arg(output)
.args(extra)
.output()
.unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
}
/// The decoded samples of `path`, scaled to 16-bit steps.
fn steps16(path: &Path) -> Vec<f64> {
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
buf.channels[0].iter().map(|s| f64::from(*s) * 32768.0).collect()
}
#[test]
fn convert_copies_samples_when_nothing_is_lost() {
let dir = tmpdir("exact");
let (silence, tone) = (dir.join("silence.wav"), dir.join("tone.wav"));
let tone_samples: Vec<i16> = (0..4800).map(|i| (8000.0 * (f64::from(i) * 0.05).sin()) as i16).collect();
write_wav16(&silence, &[0; 4800]);
write_wav16(&tone, &tone_samples);
let want: Vec<f64> = tone_samples.iter().map(|s| f64::from(*s)).collect();
// Same depth, wider and float: every sample comes back unchanged, silence stays silent.
for (depth, name) in [("16", "s16.wav"), ("24", "s24.wav"), ("32f", "s32.wav")] {
let out = dir.join(name);
convert(&silence, &out, &["--bit-depth", depth]);
assert!(steps16(&out).iter().all(|s| *s == 0.0), "silence at {depth} is not silent");
let out = dir.join(format!("t{name}"));
convert(&tone, &out, &["--bit-depth", depth]);
assert_eq!(steps16(&out), want, "tone at {depth} changed");
}
// The default output depth (24-bit) too.
let out = dir.join("default.wav");
convert(&tone, &out, &[]);
assert_eq!(steps16(&out), want);
// Asked for, dither is still applied.
let out = dir.join("forced.wav");
convert(&silence, &out, &["--bit-depth", "16", "--dither"]);
assert!(steps16(&out).iter().any(|s| *s != 0.0), "--dither did not dither");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn convert_dithers_when_reducing_depth_unless_told_not_to() {
let dir = tmpdir("reduce");
let src = dir.join("src24.wav");
// A 24-bit file with detail below one 16-bit step.
let mut buf = AudioBuffer::new(48_000, 1, 4800);
for (i, s) in buf.channels[0].iter_mut().enumerate() {
*s = 0.3 / 32768.0 * (i as f32 * 0.01).sin();
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None };
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let dithered = dir.join("d16.wav");
convert(&src, &dithered, &["--bit-depth", "16"]);
assert!(steps16(&dithered).iter().any(|s| *s != 0.0), "24 to 16-bit was not dithered");
// Plain rounding: everything is below half a step, so it all rounds to zero.
let plain = dir.join("p16.wav");
convert(&src, &plain, &["--bit-depth", "16", "--no-dither"]);
assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered");
let _ = std::fs::remove_dir_all(&dir);
}
+550
View File
@@ -0,0 +1,550 @@
//! How `soundcraft-cli` parses its arguments and reports results today, run as the real binary.
//! A change to the command line's behaviour updates them on purpose.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
/// What one run of the binary produced.
struct Invoke {
code: i32,
stdout: String,
stderr: String,
}
/// Runs the binary in `dir` with empty stdin and no control-port variable.
fn invoke(dir: &Path, args: &[&str]) -> Invoke {
invoke_env(dir, args, None)
}
fn invoke_env(dir: &Path, args: &[&str], control_port: Option<&str>) -> Invoke {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
cmd.env("SOUNDCRAFT_NO_PREFS", "1").env_remove("SOUNDCRAFT_CONTROL_PORT");
if let Some(port) = control_port {
cmd.env("SOUNDCRAFT_CONTROL_PORT", port);
}
let out = cmd.current_dir(dir).args(args).stdin(Stdio::null()).output().unwrap();
Invoke { code: out.status.code().unwrap(), stdout: String::from_utf8(out.stdout).unwrap(), stderr: String::from_utf8(out.stderr).unwrap() }
}
/// A fresh directory holding `in.wav` (200 samples, 16-bit mono, 48 kHz) and `s.txt` (a one-command script).
fn sandbox(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-behaviour-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut buf = AudioBuffer::new(48_000, 1, 200);
for (i, d) in buf.channels[0].iter_mut().enumerate() {
*d = i as f32 / 400.0;
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(dir.join("in.wav"), soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
std::fs::write(dir.join("s.txt"), "track.new {}\n").unwrap();
dir
}
/// The sample rate of the WAV `name` in `dir`.
fn rate_of(dir: &Path, name: &str) -> u32 {
soundcraft_audio_io::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_rate
}
/// Bit depth of the WAV `name` in `dir`, as the probe reports the sample format.
fn format_of(dir: &Path, name: &str) -> String {
format!("{:?}", soundcraft_audio_io::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_format)
}
/// Exit 1, nothing on stdout, and exactly `error: {message}` on stderr.
#[track_caller]
fn assert_fails(got: &Invoke, message: &str) {
assert_eq!(got.code, 1, "stderr: {}", got.stderr);
assert_eq!(got.stdout, "");
assert_eq!(got.stderr, format!("error: {message}\n"));
}
/// Exit 0 with nothing on either stream.
#[track_caller]
fn assert_silent_success(got: &Invoke) {
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "", ""));
}
/// Exit 2, nothing on stdout, and a clap usage error starting with `error: {message}` on stderr.
#[track_caller]
fn assert_usage_error(got: &Invoke, message: &str) {
assert_eq!(got.code, 2, "stderr: {}", got.stderr);
assert_eq!(got.stdout, "");
assert!(got.stderr.starts_with(&format!("error: {message}")), "{}", got.stderr);
}
/// The OS's message for a missing file: error 2 is `ENOENT` on Unix and `ERROR_FILE_NOT_FOUND` on Windows.
fn not_found() -> std::io::Error {
std::io::Error::from_raw_os_error(2)
}
/// Exit 1 with the refused-connection message for `port`; the OS's text for the refusal differs by platform.
#[track_caller]
fn assert_refused(got: &Invoke, port: u16) {
assert_eq!((got.code, got.stdout.as_str()), (1, ""));
let prefix = format!("error: cannot reach SoundCraft at 127.0.0.1:{port}: ");
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
}
// ---- unknown flags ----
#[test]
fn unknown_flags_stop_the_work_before_it_starts() {
let dir = sandbox("unknown-flag-reject");
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "out.wav", "--bogus"]), "unexpected argument '--bogus' found");
assert_usage_error(&invoke(&dir, &["run", "--bogus", "--save", "s.scraft"]), "unexpected argument '--bogus' found");
assert_usage_error(&invoke(&dir, &["script", "s.txt", "--bogus"]), "unexpected argument '--bogus' found");
// A single-dash word is a flag too.
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "out.wav", "-x"]), "unexpected argument '-x' found");
assert!(!dir.join("out.wav").exists() && !dir.join("s.scraft").exists());
}
#[test]
fn convert_flags_may_come_before_or_between_the_positionals() {
let dir = sandbox("convert-order");
let got = invoke(&dir, &["convert", "--rate", "44100", "in.wav", "a.wav"]);
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "wrote a.wav (1 ch, 44100 Hz, 0.00 s)\n", ""));
let got = invoke(&dir, &["convert", "in.wav", "--rate", "44100", "b.wav"]);
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "wrote b.wav (1 ch, 44100 Hz, 0.00 s)\n", ""));
assert_eq!((rate_of(&dir, "a.wav"), rate_of(&dir, "b.wav")), (44_100, 44_100));
}
// ---- value flags ----
#[test]
fn convert_value_flags_take_a_separate_or_inline_value() {
let dir = sandbox("convert-values");
assert_eq!(invoke(&dir, &["convert", "in.wav", "c.wav", "--rate", "44100"]).stdout, "wrote c.wav (1 ch, 44100 Hz, 0.00 s)\n");
assert_eq!(invoke(&dir, &["convert", "in.wav", "d.wav", "--rate=44100"]).stdout, "wrote d.wav (1 ch, 44100 Hz, 0.00 s)\n");
assert_eq!(rate_of(&dir, "c.wav"), 44_100);
assert_eq!(rate_of(&dir, "d.wav"), 44_100);
assert_eq!(invoke(&dir, &["convert", "in.wav", "e.wav", "--bit-depth", "16"]).code, 0);
assert_eq!(invoke(&dir, &["convert", "in.wav", "f.wav", "--bit-depth=32f"]).code, 0);
assert_eq!(format_of(&dir, "e.wav"), "Int16");
assert_eq!(format_of(&dir, "f.wav"), "Float32");
assert_eq!(invoke(&dir, &["convert", "in.wav", "h.wav", "--bit-depth", "32"]).code, 0);
assert_eq!(format_of(&dir, "h.wav"), "Float32");
assert_eq!(invoke(&dir, &["convert", "in.wav", "g.wav"]).code, 0);
assert_eq!(format_of(&dir, "g.wav"), "Int24");
}
#[test]
fn convert_value_flag_without_a_value_or_with_a_bad_one_fails() {
let dir = sandbox("convert-bad-values");
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--rate"]), "a value is required for '--rate <HZ>' but none was supplied");
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--bit-depth"]), "a value is required for '--bit-depth <16|24|32f>'");
// A following flag is the next flag, not the value.
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--rate", "--normalize"]), "a value is required for '--rate <HZ>'");
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--normalize=1"]), "unexpected value '1' for '--normalize' found");
assert!(!dir.join("o.wav").exists());
}
#[test]
fn repeated_flags_are_usage_errors_in_either_spelling() {
let dir = sandbox("repeat");
for args in [
&["convert", "in.wav", "a.wav", "--rate", "44100", "--rate", "22050"][..],
&["convert", "in.wav", "a.wav", "--rate=22050", "--rate", "44100"],
&["convert", "in.wav", "a.wav", "--bit-depth", "16", "--bit-depth", "24"],
&["convert", "in.wav", "a.wav", "--normalize", "--normalize"],
&["run", "--save", "a.scraft", "--save", "b.scraft"],
&["run", "--demo", "--demo"],
&["run", "--demo", "--sample"],
&["script", "s.txt", "--keep-going", "--keep-going"],
&["app", "--port", "1", "--port", "2", "x"],
&["mcp", "--demo", "--demo"],
] {
let got = invoke(&dir, args);
assert_usage_error(&got, "the argument '--");
assert_eq!(got.code, 2, "{args:?}");
}
assert!(!dir.join("a.wav").exists() && !dir.join("a.scraft").exists());
}
#[test]
fn convert_input_and_output_errors() {
let dir = sandbox("convert-io");
assert_fails(&invoke(&dir, &["convert", "nope.wav", "o.wav"]), &format!("nope.wav: {}", not_found()));
assert_fails(&invoke(&dir, &["convert", "in.wav", "o.xyz"]), "o.xyz: cannot write `.xyz` files (supported: wav, wave, bwf, aif, aiff, flac)");
}
#[test]
fn run_value_flags_take_a_separate_or_inline_value() {
let dir = sandbox("run-values");
let tracks = "track.new: {\"tracks\":[1]}\n";
assert_eq!(invoke(&dir, &["run", "--cmd", "track.new"]).stdout, tracks);
assert_eq!(invoke(&dir, &["run", "--cmd=track.new"]).stdout, tracks);
let save = invoke(&dir, &["run", "--save", "s1.scraft"]);
assert_eq!((save.code, save.stdout.as_str()), (0, "{\"audio_files_written\":0,\"path\":\"s1.scraft\"}\n"));
let save = invoke(&dir, &["run", "--save=s2.scraft"]);
assert_eq!((save.code, save.stdout.as_str()), (0, "{\"audio_files_written\":0,\"path\":\"s2.scraft\"}\n"));
assert!(dir.join("s1.scraft").exists() && dir.join("s2.scraft").exists());
// --in in both spellings loads the saved session.
for args in [["run", "--in", "s1.scraft", "--inspect"], ["run", "--in=s1.scraft", "--inspect", ""]] {
let args: Vec<&str> = args.into_iter().filter(|a| !a.is_empty()).collect();
let got = invoke(&dir, &args);
assert_eq!(got.code, 0, "{args:?}: {}", got.stderr);
assert!(got.stdout.contains("\"dirty\": false"), "{args:?}: {}", got.stdout);
}
// --bounce with --start/--end in both spellings.
let spaced = invoke(&dir, &["run", "--demo", "--bounce", "b1.wav", "--start", "0", "--end", "0.1"]);
assert_eq!(spaced.code, 0, "{}", spaced.stderr);
assert!(spaced.stdout.contains("\"path\":\"b1.wav\"") && spaced.stdout.contains("\"seconds\":0.1"), "{}", spaced.stdout);
let inline = invoke(&dir, &["run", "--demo", "--bounce=b2.wav", "--start=0", "--end=0.1"]);
assert_eq!(inline.code, 0, "{}", inline.stderr);
assert!(inline.stdout.contains("\"path\":\"b2.wav\"") && inline.stdout.contains("\"seconds\":0.1"), "{}", inline.stdout);
assert!(dir.join("b1.wav").exists() && dir.join("b2.wav").exists());
}
#[test]
fn run_cmd_repeats_and_runs_every_occurrence_in_order() {
let dir = sandbox("run-repeat");
assert_eq!(invoke(&dir, &["run", "--cmd", "track.new", "--cmd=track.new"]).stdout, "track.new: {\"tracks\":[1]}\ntrack.new: {\"tracks\":[2]}\n");
}
#[test]
fn negative_times_are_values_but_other_dash_words_are_not() {
let dir = sandbox("negative-time");
// The engine counts a position before 0, so the bounce starts a second ahead of the session.
for args in [&["--start", "-1"][..], &["--start=-1"]] {
let mut full = vec!["run", "--demo", "--end", "0.1", "--bounce", "b.wav"];
full.extend(args);
let got = invoke(&dir, &full);
assert_eq!(got.code, 0, "{}", got.stderr);
assert!(got.stdout.contains("\"seconds\":1.1"), "{}", got.stdout);
}
assert_usage_error(&invoke(&dir, &["run", "--bounce", "-x.wav"]), "unexpected argument '-x' found");
assert_usage_error(&invoke(&dir, &["run", "--cmd", "-x"]), "unexpected argument '-x' found");
}
#[test]
fn script_runs_lines_and_takes_options_before_or_after_the_file() {
let dir = sandbox("script");
let got = invoke(&dir, &["script", "s.txt"]);
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "track.new: {\"tracks\":[1]}\n", ""));
let got = invoke(&dir, &["script", "s.txt", "--save", "sv.scraft"]);
assert_eq!(got.stdout, "track.new: {\"tracks\":[1]}\n{\"audio_files_written\":0,\"path\":\"sv.scraft\"}\n");
assert!(dir.join("sv.scraft").exists());
assert_eq!(invoke(&dir, &["script", "s.txt", "--keep-going"]).stdout, "track.new: {\"tracks\":[1]}\n");
assert_eq!(invoke(&dir, &["script", "--keep-going", "s.txt"]).stdout, "track.new: {\"tracks\":[1]}\n");
assert_fails(&invoke(&dir, &["script", "nope.txt"]), &format!("nope.txt: {}", not_found()));
assert_fails(&invoke(&dir, &["script", "s.txt", "--in=nope.scraft"]), &format!("nope.scraft: {}", not_found()));
}
#[test]
fn script_line_errors_stop_unless_keep_going() {
let dir = sandbox("script-errors");
std::fs::write(dir.join("bad.txt"), "nosuch.cmd {}\ntrack.new {}\n").unwrap();
std::fs::write(dir.join("badjson.txt"), "track.new {x\n").unwrap();
// A stopping line error carries the `error:` prefix; a --keep-going one is a bare line.
let stop = invoke(&dir, &["script", "bad.txt"]);
assert_eq!((stop.code, stop.stdout.as_str()), (1, ""));
assert_eq!(stop.stderr, "error: bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
let keep = invoke(&dir, &["script", "bad.txt", "--keep-going"]);
assert_eq!((keep.code, keep.stdout.as_str()), (0, "track.new: {\"tracks\":[1]}\n"));
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
assert_fails(&invoke(&dir, &["script", "badjson.txt"]), "badjson.txt:1: bad JSON: key must be a string at line 1 column 2");
}
#[test]
fn info_prints_json_for_audio_and_fails_for_missing_files() {
let dir = sandbox("info");
let got = invoke(&dir, &["info", "in.wav"]);
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
assert!(
got.stdout.contains("\"channels\": 1")
&& got.stdout.contains("\"sample_rate\": 48000")
&& got.stdout.contains("\"sample_format\": \"Int16\""),
"{}",
got.stdout
);
assert_fails(&invoke(&dir, &["info", "nope.wav"]), &format!("nope.wav: {}", not_found()));
}
/// The extension decides a session in any case, as the desktop app's `open_path` does.
#[test]
fn info_reads_a_session_whatever_the_extension_case() {
let dir = sandbox("info-session-case");
assert_eq!(invoke(&dir, &["run", "--demo", "--save", "S.SCRAFT"]).code, 0);
let got = invoke(&dir, &["info", "S.SCRAFT"]);
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
assert!(got.stdout.starts_with("SESSION NAME:"), "{}", got.stdout);
}
#[test]
fn info_compact_is_the_same_json_on_one_line() {
let dir = sandbox("info-compact");
let (pretty, compact) = (invoke(&dir, &["info", "in.wav"]), invoke(&dir, &["info", "in.wav", "--compact"]));
assert_eq!((compact.code, compact.stderr.as_str()), (0, ""));
assert_eq!(compact.stdout.trim_end().lines().count(), 1, "{}", compact.stdout);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&pretty.stdout).unwrap(),
serde_json::from_str::<serde_json::Value>(&compact.stdout).unwrap()
);
}
#[test]
fn parity_writes_a_report_with_write_in_both_spellings() {
let dir = sandbox("parity");
let stdout_report = invoke(&dir, &["parity"]);
assert_eq!(stdout_report.code, 0);
assert!(stdout_report.stdout.starts_with("# Parity report\n"), "{}", stdout_report.stdout);
for (flag, name) in [(vec!["--write", "p1.md"], "p1.md"), (vec!["--write=p2.md"], "p2.md")] {
let mut args = vec!["parity"];
args.extend(flag);
let got = invoke(&dir, &args);
assert_eq!(got.code, 0, "{}", got.stderr);
assert!(got.stdout.starts_with(&format!("wrote {name}: ")), "{}", got.stdout);
assert!(std::fs::read_to_string(dir.join(name)).unwrap().starts_with("# Parity report\n"));
}
assert_usage_error(&invoke(&dir, &["parity", "--write"]), "a value is required for '--write <PATH>' but none was supplied");
}
// ---- outputs that replace an input ----
/// `in.wav`, `s.txt` and a saved session `s.scraft`.
fn sandbox_with_session(tag: &str) -> PathBuf {
let dir = sandbox(tag);
assert_eq!(invoke(&dir, &["run", "--save", "s.scraft"]).code, 0);
dir
}
#[test]
fn an_output_that_is_an_input_is_refused_before_any_work() {
let dir = sandbox_with_session("out-is-in");
std::fs::hard_link(dir.join("in.wav"), dir.join("hard.wav")).unwrap();
let (wav, session, script) =
(std::fs::read(dir.join("in.wav")).unwrap(), std::fs::read(dir.join("s.scraft")).unwrap(), std::fs::read(dir.join("s.txt")).unwrap());
let in_wav = dir.join("in.wav");
let absolute = in_wav.to_str().unwrap();
for out in ["in.wav", "./in.wav", "../{}/in.wav", absolute, "hard.wav"] {
let out = out.replace("{}", dir.file_name().unwrap().to_str().unwrap());
let got = invoke(&dir, &["convert", "in.wav", &out, "--rate", "44100"]);
assert_usage_error(&got, &format!("<OUT> {out} is the <IN> file, which it would replace: choose another file, or pass --in-place"));
}
// Nothing ran: no --cmd result, no bounce, no save.
let refused: &[(&[&str], &str)] = &[
(&["run", "--in", "s.scraft", "--cmd", "track.new", "--save", "s.scraft"], "--save s.scraft is the --in session"),
(&["run", "--in=./s.scraft", "--cmd", "track.new", "--bounce", "s.scraft"], "--bounce s.scraft is the --in session"),
// --in-place lets only --save replace the session.
(
&["run", "--in", "s.scraft", "--in-place", "--cmd", "track.new", "--bounce", "./s.scraft"],
"--bounce ./s.scraft is the --in session, which it would replace with audio: choose another file",
),
(&["script", "s.txt", "--in", "s.scraft", "--in-place", "--bounce", "s.scraft"], "--bounce s.scraft is the --in session"),
(&["script", "s.txt", "--in", "s.scraft", "--save", "./s.scraft"], "--save ./s.scraft is the --in session"),
(&["script", "s.txt", "--save", "./s.txt"], "--save ./s.txt is the script file, which it would replace: choose another file"),
(&["script", "s.txt", "--in", "s.scraft", "--in-place", "--bounce", "s.txt"], "--bounce s.txt is the script file"),
(&["run", "--demo", "--cmd", "track.new", "--bounce", "b.wav", "--save", "./b.wav"], "--bounce and --save both name ./b.wav"),
];
for (args, message) in refused {
let got = invoke(&dir, args);
assert_usage_error(&got, message);
assert!(got.stderr.contains("\nUsage: soundcraft-cli "), "{}", got.stderr);
}
assert_eq!(std::fs::read(dir.join("in.wav")).unwrap(), wav);
assert_eq!(std::fs::read(dir.join("s.scraft")).unwrap(), session);
assert_eq!(std::fs::read(dir.join("s.txt")).unwrap(), script);
assert!(!dir.join("b.wav").exists());
assert_usage_error(
&invoke(&dir, &["run", "--in-place", "--save", "s.scraft"]),
"the following required arguments were not provided:\n --in <S.scraft>",
);
}
#[cfg(unix)]
#[test]
fn an_output_linked_to_the_input_is_refused() {
let dir = sandbox_with_session("out-is-in-link");
std::os::unix::fs::symlink("in.wav", dir.join("link.wav")).unwrap();
std::os::unix::fs::symlink(".", dir.join("here")).unwrap();
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "link.wav"]), "<OUT> link.wav is the <IN> file");
assert_usage_error(&invoke(&dir, &["run", "--in", "s.scraft", "--save", "here/s.scraft"]), "--save here/s.scraft is the --in session");
// Two outputs that don't exist yet, one through a linked folder.
assert_usage_error(&invoke(&dir, &["run", "--bounce", "here/b.wav", "--save", "b.wav"]), "--bounce and --save both name b.wav");
assert_eq!(rate_of(&dir, "in.wav"), 48_000);
}
#[test]
fn in_place_replaces_the_input_and_other_files_are_still_replaced() {
let dir = sandbox_with_session("in-place");
let got = invoke(&dir, &["convert", "in.wav", "./in.wav", "--rate", "44100", "--in-place"]);
assert_eq!(got.code, 0, "{}", got.stderr);
assert_eq!(rate_of(&dir, "in.wav"), 44_100);
let got = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new", "--save", "s.scraft", "--in-place"]);
assert_eq!(got.stdout, "track.new: {\"tracks\":[1]}\n{\"audio_files_written\":0,\"path\":\"s.scraft\"}\n", "{}", got.stderr);
// The saved session holds that track, so the next one is the second.
assert_eq!(invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new"]).stdout, "track.new: {\"tracks\":[2]}\n");
// An existing file that is no input is replaced without --in-place.
for rate in ["32000", "22050"] {
let got = invoke(&dir, &["convert", "in.wav", "o.wav", "--rate", rate]);
assert_eq!(got.code, 0, "{}", got.stderr);
}
assert_eq!(rate_of(&dir, "o.wav"), 22_050);
// other.scraft gets the script's track, then is replaced by s.scraft as it is.
let next_track = |session: &str| invoke(&dir, &["run", "--in", session, "--cmd", "track.new"]).stdout;
let got = invoke(&dir, &["script", "s.txt", "--in", "s.scraft", "--save", "other.scraft"]);
assert_eq!(got.code, 0, "{}", got.stderr);
assert_eq!(next_track("other.scraft"), "track.new: {\"tracks\":[3]}\n");
let got = invoke(&dir, &["run", "--in", "s.scraft", "--save", "other.scraft"]);
assert_eq!(got.code, 0, "{}", got.stderr);
assert_eq!(next_track("other.scraft"), "track.new: {\"tracks\":[2]}\n");
}
/// A write that fails leaves the input as it was: the new bytes go to a new file first.
#[cfg(unix)]
#[test]
fn failed_in_place_convert_keeps_the_input() {
use std::os::unix::fs::PermissionsExt;
let dir = sandbox("in-place-fails");
let before = std::fs::read(dir.join("in.wav")).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let can_create = std::fs::File::create(dir.join("probe")).is_ok();
if !can_create {
let got = invoke(&dir, &["convert", "in.wav", "in.wav", "--rate", "44100", "--in-place"]);
assert_eq!(got.code, 1, "{}", got.stderr);
assert_eq!(std::fs::read(dir.join("in.wav")).unwrap(), before);
}
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(std::fs::read_dir(&dir).unwrap().all(|e| !e.unwrap().file_name().to_string_lossy().ends_with(".part")));
}
/// An in-place output that is a link to the input replaces the input, and the link stays a link.
#[cfg(unix)]
#[test]
fn in_place_through_a_link_replaces_the_linked_input() {
let dir = sandbox_with_session("in-place-link");
std::os::unix::fs::symlink("in.wav", dir.join("link.wav")).unwrap();
std::os::unix::fs::symlink("s.scraft", dir.join("link.scraft")).unwrap();
let got = invoke(&dir, &["convert", "in.wav", "link.wav", "--rate", "44100", "--in-place"]);
assert_eq!(got.stdout, "wrote link.wav (1 ch, 44100 Hz, 0.00 s)\n", "{}", got.stderr);
assert_eq!(rate_of(&dir, "in.wav"), 44_100);
let got = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new", "--save", "link.scraft", "--in-place"]);
assert_eq!(got.code, 0, "{}", got.stderr);
// s.scraft holds that track, so the next one is the second.
assert_eq!(invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new"]).stdout, "track.new: {\"tracks\":[2]}\n");
for link in ["link.wav", "link.scraft"] {
assert!(dir.join(link).symlink_metadata().unwrap().file_type().is_symlink(), "{link}");
}
}
// ---- app ----
#[test]
fn app_port_comes_from_the_flag_then_the_environment() {
let dir = sandbox("app-env");
assert_refused(&invoke_env(&dir, &["app", "session.inspect"], Some("1")), 1);
assert_refused(&invoke_env(&dir, &["app", "--port", "2", "session.inspect"], Some("1")), 2);
// The flag wins before the variable is read, so a bad one doesn't matter.
assert_refused(&invoke_env(&dir, &["app", "--port", "2", "session.inspect"], Some("abc")), 2);
}
#[test]
fn app_takes_an_empty_port_variable_as_unset() {
if std::net::TcpStream::connect("127.0.0.1:7979").is_ok() {
eprintln!("skipped: something listens on 127.0.0.1:7979, the default port");
return;
}
let dir = sandbox("app-env-empty");
for empty in ["", " "] {
assert_refused(&invoke_env(&dir, &["app", "session.inspect"], Some(empty)), 7979);
}
}
#[test]
fn a_bad_port_variable_is_a_usage_error_naming_it() {
let dir = sandbox("app-env-bad");
for bad in ["abc", "70000", ":80"] {
assert_usage_error(
&invoke_env(&dir, &["app", "session.inspect"], Some(bad)),
&format!("invalid value '{bad}' for 'SOUNDCRAFT_CONTROL_PORT': expected a port, 0-65535, or host:port"),
);
}
}
#[test]
fn app_help_names_the_port_variable() {
let got = invoke(&sandbox("app-help"), &["app", "--help"]);
assert_eq!(got.code, 0, "{}", got.stderr);
assert!(got.stdout.contains("[env: SOUNDCRAFT_CONTROL_PORT=]\n [default: 7979]\n"), "{}", got.stdout);
}
// ---- mcp ----
#[test]
fn mcp_accepts_its_flags_in_both_spellings_and_exits_cleanly_on_empty_stdin() {
let dir = sandbox("mcp-ok");
for args in [vec!["mcp"], vec!["mcp", "--demo"], vec!["mcp", "--connect", "1"], vec!["mcp", "--connect=1"]] {
assert_silent_success(&invoke(&dir, &args));
}
// A root prints the confinement line to stderr (never stdout).
for args in [&["mcp", "--automation-read-root", "."][..], &["mcp", "--automation-read-root=."][..]] {
let got = invoke(&dir, args);
assert_eq!((got.code, got.stdout.as_str()), (0, ""));
assert!(got.stderr.starts_with("soundcraft-cli: files confined: read ") && got.stderr.ends_with("; write none\n"), "{}", got.stderr);
}
// A repeated write-root flag is accepted in either spelling.
let twice = invoke(&dir, &["mcp", "--automation-write-root=.", "--automation-write-root", "."]);
assert_eq!(twice.code, 0);
assert!(
twice.stderr.starts_with("soundcraft-cli: files confined: read none; write ") && !twice.stderr.contains("; write none"),
"{}",
twice.stderr
);
}
#[test]
fn mcp_demo_with_connect_is_a_usage_error() {
let dir = sandbox("mcp-demo-connect");
assert_usage_error(&invoke(&dir, &["mcp", "--demo", "--connect", "1"]), "the argument '--demo' cannot be used with '--connect <PORT>'");
}
#[test]
fn mcp_roots_with_connect_are_a_usage_error() {
let dir = sandbox("mcp-roots-connect");
// Refused before any folder is opened, so a missing one isn't reported.
assert_usage_error(
&invoke(&dir, &["mcp", "--connect", "1", "--automation-write-root", "missing"]),
"the argument '--connect <PORT>' cannot be used with '--automation-write-root <DIR>'",
);
assert_usage_error(
&invoke(&dir, &["mcp", "--automation-read-root=.", "--connect=1"]),
"the argument '--automation-read-root <DIR>' cannot be used with '--connect <PORT>'",
);
}
/// A name that isn't valid Unicode works wherever the tool itself reads or writes the file
/// (`convert`); a session or bounce the engine opens or writes by text name is refused (exit 1),
/// naming the file. Run in-process: the names need no shell.
#[cfg(unix)]
#[test]
fn non_unicode_file_names() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let dir = sandbox("nonunicode");
let latin1 = |ext: &str| {
let mut n = b"caf\xe9.".to_vec();
n.extend_from_slice(ext.as_bytes());
dir.join(OsString::from_vec(n))
};
let run = |args: Vec<OsString>| {
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = soundcraft_cli::run(args, &mut out, &mut err);
(code, String::from_utf8_lossy(&err).into_owned())
};
let (code, err) = run(vec!["convert".into(), dir.join("in.wav").into(), latin1("flac").into()]);
assert_eq!(code, 0, "{err}");
assert!(std::fs::metadata(latin1("flac")).unwrap().len() > 0);
// A session opens by path; only paths that go into the engine's JSON must be Unicode.
let (code, err) = run(vec!["run".into(), "--demo".into(), "--save".into(), dir.join("s.scraft").into()]);
assert_eq!(code, 0, "{err}");
std::fs::rename(dir.join("s.scraft"), latin1("scraft")).unwrap();
let (code, err) = run(vec!["info".into(), latin1("scraft").into()]);
assert_eq!(code, 0, "{err}");
let (code, err) = run(vec!["run".into(), "--demo".into(), "--bounce".into(), latin1("wav").into()]);
assert_eq!(code, 1);
assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
+255
View File
@@ -0,0 +1,255 @@
//! `soundcraft-cli mcp` with the automation root flags, run as the real binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use serde_json::Value;
fn bin() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
cmd
}
fn run(args: &[&str]) -> Output {
bin().args(args).output().unwrap()
}
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-roots-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// `path` made absolute with links followed (`/var` becomes `/private/var` on macOS), as the server
/// prints a root: without the `\\?\` prefix that `canonicalize` adds on Windows.
fn resolved(path: &Path) -> PathBuf {
let path = std::fs::canonicalize(path).unwrap();
if !cfg!(windows) {
return path;
}
let text = path.to_str().unwrap();
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
text.strip_prefix(r"\\?\").map_or(path.clone(), PathBuf::from)
}
/// Send `calls` (tool name, arguments) to `soundcraft-cli mcp <args>`; returns whether it exited
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
mcp_session_in(None, args, calls)
}
/// [`mcp_session`] with the server started in the working directory `cwd`.
fn mcp_session_in(cwd: Option<&Path>, args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
let mut cmd = bin();
if let Some(cwd) = cwd {
cmd.current_dir(cwd);
}
let mut child = cmd.arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
for (i, (name, arguments)) in calls.iter().enumerate() {
let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
writeln!(stdin, "{call}").unwrap();
}
}
let out = child.wait_with_output().unwrap();
let stdout = String::from_utf8(out.stdout).unwrap();
let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
(out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies)
}
fn text(reply: &Value) -> &str {
reply["result"]["content"][0]["text"].as_str().unwrap()
}
/// A short WAV made by the binary itself.
fn make_wav(path: &std::path::Path) {
let o = run(&["run", "--demo", "--bounce", path.to_str().unwrap(), "--start", "0", "--end", "0.1"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
}
#[test]
fn mcp_automation_roots_confine_the_server() {
let base = tmpdir("roots");
let (work, elsewhere) = (base.join("work"), base.join("elsewhere"));
std::fs::create_dir_all(&work).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
make_wav(&work.join("in.wav"));
make_wav(&elsewhere.join("secret.wav"));
let work_s = work.to_str().unwrap();
let secret = elsewhere.join("secret.wav").to_str().unwrap().to_string();
// Both spellings of the flags; inside works, outside is refused with a readable message.
let read_flag = format!("--automation-read-root={work_s}");
let calls = [
("import_audio", serde_json::json!({"path": work.join("in.wav").to_str().unwrap()})),
("import_audio", serde_json::json!({"path": secret})),
("save_session", serde_json::json!({"path": work.join("saved.scraft").to_str().unwrap()})),
("save_session", serde_json::json!({"path": elsewhere.join("saved.scraft").to_str().unwrap()})),
("bounce_mix", serde_json::json!({"path": elsewhere.join("mix.wav").to_str().unwrap()})),
];
let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls);
assert!(success, "{stderr}");
assert!(stderr.contains("files confined"), "{stderr}");
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
assert_eq!(replies[1]["result"]["isError"], true);
assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1]));
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
assert!(work.join("saved.scraft").exists());
assert_eq!(replies[3]["result"]["isError"], true);
assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3]));
assert_eq!(replies[4]["result"]["isError"], true);
assert!(text(&replies[4]).contains("outside the write root"), "{}", text(&replies[4]));
assert!(!elsewhere.join("saved.scraft").exists() && !elsewhere.join("mix.wav").exists());
// A root left out grants none of its access.
let (_, _, replies) = mcp_session(&[&read_flag], &calls[2..3]);
assert_eq!(replies[0]["result"]["isError"], true);
assert!(text(&replies[0]).contains("write authority is absent"), "{}", text(&replies[0]));
let (_, _, replies) = mcp_session(&["--automation-write-root", work_s], &calls[0..1]);
assert_eq!(replies[0]["result"]["isError"], true);
assert!(text(&replies[0]).contains("read authority is absent"), "{}", text(&replies[0]));
// No flags: nothing is confined.
let (success, _, replies) = mcp_session(&[], &calls[1..2]);
assert!(success);
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
let _ = std::fs::remove_dir_all(base);
}
/// #874: each root flag may be given more than once. A path inside any root of its kind passes;
/// the two roots' common parent, `..` out of a root and a link out of one stay refused.
#[test]
fn mcp_automation_roots_take_several_folders() {
let base = resolved(&tmpdir("several"));
for d in ["in", "in2", "out", "out2", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
for f in ["in/a.wav", "in2/b.wav", "outside/secret.wav", "parent.wav"] {
make_wav(&base.join(f));
}
let p = |f: &str| base.join(f).to_str().unwrap().to_string();
let sep = std::path::MAIN_SEPARATOR;
let calls = [
("import_audio", serde_json::json!({"path": p("in/a.wav")})),
("import_audio", serde_json::json!({"path": p("in2/b.wav")})),
// The roots' common parent is not a root.
("import_audio", serde_json::json!({"path": p("parent.wav")})),
("import_audio", serde_json::json!({"path": format!("{}{sep}..{sep}outside{sep}secret.wav", p("in2"))})),
("bounce_mix", serde_json::json!({"path": p("out/a.wav")})),
("bounce_mix", serde_json::json!({"path": p("out2/b.wav")})),
("bounce_mix", serde_json::json!({"path": p("x.wav")})),
// Read and write stay separate: a read root is not a write root, nor the other way round.
("bounce_mix", serde_json::json!({"path": p("in2/c.wav")})),
("import_audio", serde_json::json!({"path": p("out2/b.wav")})),
];
let read2 = format!("--automation-read-root={}", p("in2"));
let args = ["--automation-read-root", &p("in"), &read2, "--automation-write-root", &p("out"), "--automation-write-root", &p("out2")];
let (success, stderr, replies) = mcp_session(&args, &calls);
assert!(success, "{stderr}");
assert!(stderr.contains(&format!("read {}, {}", p("in"), p("in2"))), "{stderr}");
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, [false, false, true, true, false, false, true, true, true], "{replies:?}");
for (i, kind) in [(2, "read"), (3, "read"), (6, "write"), (7, "write"), (8, "read")] {
assert!(text(&replies[i]).contains(&format!("outside the {kind} roots ")), "{}", text(&replies[i]));
}
assert!(base.join("out/a.wav").is_file() && base.join("out2/b.wav").is_file());
assert!(!base.join("x.wav").exists() && !base.join("in2/c.wav").exists());
// A link in one root that leads out of all of them is outside.
#[cfg(unix)]
{
std::os::unix::fs::symlink(base.join("outside/secret.wav"), base.join("in2/leak.wav")).unwrap();
let (_, _, replies) = mcp_session(&args, &[("import_audio", serde_json::json!({"path": p("in2/leak.wav")}))]);
assert!(text(&replies[0]).contains("outside the read roots"), "{}", text(&replies[0]));
}
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_bare_save_session_name_lands_in_the_working_directory_inside_the_roots() {
let work = tmpdir("bare-name");
let work_s = work.to_str().unwrap();
let calls = [
("new_session", serde_json::json!({"name": "t", "sample_rate": 48000})),
("execute", serde_json::json!({"command": "track.new", "params": {"kind": "audio", "name": "Tone", "format": "mono"}})),
("save_session", serde_json::json!({"path": "t.scraft"})),
];
let (success, stderr, replies) = mcp_session_in(Some(&work), &["--automation-read-root", work_s, "--automation-write-root", work_s], &calls);
assert!(success, "{stderr}");
for reply in &replies {
assert_eq!(reply["result"]["isError"], false, "{reply}");
}
assert!(work.join("t.scraft").is_file());
let _ = std::fs::remove_dir_all(work);
}
#[test]
fn mcp_automation_root_flags_are_checked_up_front() {
let base = tmpdir("roots-args");
let dir = base.to_str().unwrap();
let missing = base.join("missing");
let fails = |args: &[&str], needle: &str| {
let mut full = vec!["mcp"];
full.extend_from_slice(args);
let o = run(&full);
assert!(!o.status.success(), "{args:?}");
let err = String::from_utf8_lossy(&o.stderr).into_owned();
assert!(err.contains(needle), "{args:?}: {err}");
};
// The flags confine the in-process server, so they cannot be combined with --connect.
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
// #874: a flag may be given again, but every folder it names must exist.
fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
fails(&["--automation-write-root", dir, "--automation-write-root"], "a value is required");
fails(&["--automation-read-root"], "a value is required");
fails(&["--automation-write-root", "--demo"], "a value is required");
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
fails(&["--automation-write-root="], "a value is required");
// A misspelled root flag must not start an unconfined server.
fails(&["--automation-read-roots", dir], "unexpected argument '--automation-read-roots'");
// A file is not a folder.
std::fs::write(base.join("f.txt"), "x").unwrap();
fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn usage_lists_the_root_flags() {
let o = run(&["mcp", "--help"]);
let help = String::from_utf8_lossy(&o.stdout).into_owned();
assert!(help.contains("--automation-read-root <DIR>") && help.contains("--automation-write-root <DIR>"), "{help}");
}
/// Without `--start`/`--end`, `--bounce` covers the whole session, not a stray edit selection
/// left by the script (a bare `19.2` in command params is 19 samples).
#[test]
fn script_bounce_without_range_covers_the_session() {
let base = tmpdir("bounce-range");
let script = base.join("cmds.txt");
std::fs::write(
&script,
concat!(
"track.new {\"count\":1,\"kind\":\"instrument\",\"name\":\"Keys\"}\n",
"midi.new_clip {\"track\":\"Keys\",\"start\":0,\"end\":{\"seconds\":19.2}}\n",
"edit.select {\"start\":0,\"end\":19.2}\n",
),
)
.unwrap();
let out = base.join("out.wav");
let o = run(&["script", script.to_str().unwrap(), "--bounce", out.to_str().unwrap()]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let stdout = String::from_utf8_lossy(&o.stdout).into_owned();
let last: Value = serde_json::from_str(stdout.lines().last().unwrap()).unwrap();
let secs = last["seconds"].as_f64().unwrap();
assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}");
let _ = std::fs::remove_dir_all(base);
}
@@ -0,0 +1,124 @@
//! A closed stdout must not abort `run` or `script` before `--bounce` and `--save`.
use std::process::{Command, Stdio};
fn bin() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
cmd
}
/// Drop the read end so the child's stdout writes fail with `EPIPE`.
fn closed_stdout() -> std::process::Stdio {
let (reader, writer) = std::io::pipe().expect("pipe");
drop(reader);
Stdio::from(writer)
}
fn temp_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn run_with_closed_stdout_still_bounces_and_saves() {
let dir = temp_dir("run");
let wav = dir.join("mix.wav");
let session = dir.join("Synthetic.scraft");
let output = bin()
.arg("run")
.arg("--cmd")
.arg(r#"track.new={"name":"Synthetic","count":1,"format":"stereo"}"#)
.arg("--bounce")
.arg(&wav)
.arg("--save")
.arg(&session)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(closed_stdout())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(wav.is_file(), "bounce was skipped");
assert!(session.is_file(), "save was skipped");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn script_with_closed_stdout_still_bounces_and_saves() {
let dir = temp_dir("script");
let script = dir.join("edit.scraft.txt");
std::fs::write(&script, "track.new {\"name\":\"Synthetic\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
let wav = dir.join("mix.wav");
let session = dir.join("Synthetic.scraft");
let output = bin()
.arg("script")
.arg(&script)
.arg("--bounce")
.arg(&wav)
.arg("--save")
.arg(&session)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(closed_stdout())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(wav.is_file());
assert!(session.is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bounce_then_save_with_closed_stdout_writes_the_wav() {
let dir = temp_dir("bounce");
let wav = dir.join("mix.wav");
let session = dir.join("Empty.scraft");
let output = bin()
.arg("run")
.arg("--bounce")
.arg(&wav)
.arg("--save")
.arg(&session)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(closed_stdout())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(wav.is_file());
assert!(session.is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn invalid_bounce_destination_still_fails() {
let dir = temp_dir("bad-bounce");
let missing = dir.join("no-such-dir").join("mix.wav");
let output = bin()
.arg("run")
.arg("--bounce")
.arg(&missing)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(!output.status.success(), "a bounce that cannot be written must fail");
assert!(!missing.exists());
let _ = std::fs::remove_dir_all(&dir);
}
+11
View File
@@ -0,0 +1,11 @@
//! Integration tests, built as one test binary: each file is a module.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod behaviour;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
mod closed_stdout;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod man_and_completions;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod run;
@@ -0,0 +1,55 @@
//! The clap definition is consistent, and every shell's completions and every man page
//! (`examples/man-and-completions.rs`) can be generated from it.
use clap::ValueEnum;
use clap_complete::Shell;
// The example itself, so the test writes the files exactly as the packages get them.
#[allow(dead_code)]
#[path = "../../examples/man-and-completions.rs"]
mod example;
#[test]
fn command_is_consistent() {
soundcraft_cli::command().debug_assert();
}
#[test]
fn completions_generate_for_every_shell() {
for &shell in Shell::value_variants() {
let mut out = Vec::new();
clap_complete::generate(shell, &mut soundcraft_cli::command(), "soundcraft-cli", &mut out);
assert!(!out.is_empty(), "{shell}");
}
}
#[test]
fn man_pages_render_for_the_command_and_every_subcommand() {
let cmd = soundcraft_cli::command();
for page in std::iter::once(&cmd).chain(cmd.get_subcommands()) {
let mut out = Vec::new();
clap_mangen::Man::new(page.clone()).render(&mut out).unwrap();
assert!(!out.is_empty(), "{}", page.get_name());
}
}
/// The example writes the five completion files under the names `packaging/linux/package.sh` and
/// `packaging/freebsd/package.sh` install, `soundcraft-cli.1` and a page per subcommand, and no
/// pages for the `help` subcommand.
#[test]
fn the_example_writes_the_files_the_packages_install() {
let dir = std::env::temp_dir().join(format!("sc-cli-{}-man-and-completions", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
example::generate(&dir).unwrap();
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
written.sort();
let completions = ["soundcraft-cli.bash", "_soundcraft-cli", "soundcraft-cli.fish", "soundcraft-cli.elv", "_soundcraft-cli.ps1"];
let subcommands = ["info", "convert", "run", "script", "commands", "describe", "app", "mcp", "parity", "plugins"];
let mut expected: Vec<String> = completions.map(String::from).into();
expected.push("soundcraft-cli.1".into());
expected.extend(subcommands.iter().map(|s| format!("soundcraft-cli-{s}.1")));
expected.sort();
assert_eq!(written, expected);
std::fs::remove_dir_all(&dir).unwrap();
}
+426
View File
@@ -0,0 +1,426 @@
//! The CLI run in-process through `soundcraft_cli::run`: the tests that need no files, working
//! directory, environment or standard input. Those that do run the real binary (`behaviour.rs`).
//! A change to the command line's behaviour updates them on purpose.
use soundcraft_cli::run;
/// What [`run`] returned and wrote.
struct Invoke {
code: i32,
stdout: String,
stderr: String,
}
fn invoke(args: &[&str]) -> Invoke {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let code = run(args.iter().copied(), &mut stdout, &mut stderr);
Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() }
}
/// Exit 1, nothing on stdout, and exactly `error: {message}` on stderr.
#[track_caller]
fn assert_fails(got: &Invoke, message: &str) {
assert_eq!(got.code, 1, "stderr: {}", got.stderr);
assert_eq!(got.stdout, "");
assert_eq!(got.stderr, format!("error: {message}\n"));
}
/// Exit 0 with nothing on either stream.
#[track_caller]
fn assert_silent_success(got: &Invoke) {
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "", ""));
}
/// Exit 2, nothing on stdout, and a clap usage error on stderr: `error: ` with `message`, ending
/// with the `--help` hint.
#[track_caller]
fn assert_usage_error(got: &Invoke, message: &str) {
assert_eq!(got.code, 2, "stderr: {}", got.stderr);
assert_eq!(got.stdout, "");
assert!(got.stderr.starts_with(&format!("error: {message}")), "{}", got.stderr);
assert!(got.stderr.ends_with("\n\nFor more information, try '--help'.\n"), "{}", got.stderr);
}
/// Exit 0, help on stdout, nothing on stderr.
#[track_caller]
fn assert_help(got: &Invoke) -> &str {
assert_eq!((got.code, got.stderr.as_str()), (0, ""), "{}", got.stdout);
&got.stdout
}
/// The OS's message for a missing file: error 2 is `ENOENT` on Unix and `ERROR_FILE_NOT_FOUND` on Windows.
fn not_found() -> std::io::Error {
std::io::Error::from_raw_os_error(2)
}
/// Exit 1 with the refused-connection message for `port`; the OS's text for the refusal differs by platform.
#[track_caller]
fn assert_refused(got: &Invoke, port: u16) {
assert_eq!((got.code, got.stdout.as_str()), (1, ""));
let prefix = format!("error: cannot reach SoundCraft at 127.0.0.1:{port}: ");
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
}
#[test]
fn top_level_help_lists_every_subcommand_on_stdout() {
let long = invoke(&["--help"]);
let help = assert_help(&long);
assert!(help.starts_with("soundcraft-cli: SoundCraft without a window\n\nUsage: soundcraft-cli <COMMAND>\n"), "{help}");
for sub in ["info", "convert", "run", "script", "commands", "describe", "app", "mcp", "parity", "plugins", "help"] {
assert!(help.contains(&format!("\n {sub} ")), "{sub}: {help}");
}
assert!(help.contains("-V, --version") && !help.contains("\n version "), "{help}");
for spelling in ["-h", "help"] {
let got = invoke(&[spelling]);
assert_eq!((got.code, got.stdout, got.stderr), (0, long.stdout.clone(), String::new()), "{spelling}");
}
}
#[test]
fn version_flags_print_the_long_version_to_stdout() {
let expected = format!("soundcraft-cli {} ({} {})\n", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
for spelling in ["--version", "-V"] {
let got = invoke(&[spelling]);
assert_eq!((got.code, got.stdout, got.stderr), (0, expected.clone(), String::new()), "{spelling}");
}
}
#[test]
fn version_is_not_a_subcommand() {
assert_usage_error(&invoke(&["version"]), "unrecognized subcommand 'version'");
}
#[test]
fn help_subcommand_shows_that_subcommands_help() {
let got = invoke(&["help", "convert"]);
let help = assert_help(&got);
assert!(help.starts_with("Convert an audio file"), "{help}");
assert_eq!(help, assert_help(&invoke(&["convert", "--help"])));
}
#[test]
fn help_names_every_format_the_command_reads_and_writes() {
use soundcraft_audio_io::FileFormat;
let read = format!("({})", FileFormat::readable_extensions().join(", "));
let write = format!("its extension picks the format ({})", FileFormat::writable_extensions().join(", "));
let convert = invoke(&["convert", "--help"]);
let convert = assert_help(&convert);
assert!(convert.contains(&read) && convert.contains(&write), "{convert}");
let info = invoke(&["info", "--help"]);
assert!(assert_help(&info).contains(&read), "{}", info.stdout);
}
#[test]
fn no_arguments_prints_the_help_to_stderr_and_exits_2() {
let got = invoke(&[]);
assert_eq!((got.code, got.stdout.as_str()), (2, ""));
assert_eq!(got.stderr, invoke(&["--help"]).stdout);
}
#[test]
fn unknown_subcommand_is_a_usage_error() {
assert_usage_error(&invoke(&["bogus"]), "unrecognized subcommand 'bogus'");
assert_usage_error(&invoke(&["bogus", "--help"]), "unrecognized subcommand 'bogus'");
// Subcommand names are case sensitive and not abbreviated.
assert_usage_error(&invoke(&["INFO"]), "unrecognized subcommand 'INFO'");
assert!(invoke(&["plug"]).stderr.contains("tip: a similar subcommand exists: 'plugins'"));
assert_usage_error(&invoke(&["--bogus"]), "unexpected argument '--bogus' found");
}
/// Each subcommand's help: its usage line, then every argument and flag it takes.
#[test]
fn every_subcommand_has_help_listing_its_arguments() {
let engine = [
"--in <S.scraft>",
"--demo",
"--sample",
"--bounce <OUT>",
"--start <T>",
"--end <T>",
"--save <S.scraft>",
"--inspect",
"--compact",
"--in-place",
];
let cases: &[(&str, &str, &[&str])] = &[
("info", "info [OPTIONS] <FILE>", &["<FILE>", "--compact"]),
(
"convert",
"convert [OPTIONS] <IN> <OUT>",
&["<IN>", "<OUT>", "--bit-depth <16|24|32f>", "--rate <HZ>", "--normalize", "--dither", "--no-dither", "--in-place"],
),
("run", "run [OPTIONS]", &["--cmd <ID[=JSON]>"]),
("script", "script [OPTIONS] <FILE>", &["<FILE>", "--keep-going"]),
("commands", "commands [FILTER]", &["[FILTER]"]),
("describe", "describe [OPTIONS] <ID>", &["<ID>", "--compact"]),
(
"app",
"app [OPTIONS] <METHOD> [JSON]",
&["--port <P>", "--compact", "<METHOD>", "[JSON]", "[env: SOUNDCRAFT_CONTROL_PORT", "[default: 7979]"],
),
(
"mcp",
"mcp [OPTIONS]",
&["--connect <PORT>", "--demo", "--automation-read-root <DIR>", "--automation-write-root <DIR>", "conflict with --connect"],
),
("parity", "parity [OPTIONS]", &["--write <PATH>"]),
("plugins", "plugins", &[]),
];
for &(sub, usage, args) in cases {
let long = invoke(&[sub, "--help"]);
let help = assert_help(&long);
assert!(help.contains(&format!("\nUsage: soundcraft-cli {usage}\n")), "{sub}: {help}");
let shared: &[&str] = if matches!(sub, "run" | "script") { &engine } else { &[] };
for arg in args.iter().chain(shared).chain(&["-h, --help"]) {
assert!(help.contains(arg), "{sub} lacks {arg}: {help}");
}
// `help SUB` is `SUB --help`; `-h` is the one-line-per-flag summary.
assert_eq!(invoke(&["help", sub]).stdout, long.stdout, "help {sub}");
assert!(assert_help(&invoke(&[sub, "-h"])).contains(&format!("\nUsage: soundcraft-cli {usage}\n")), "{sub} -h");
}
}
#[test]
fn help_wins_over_a_mistake_before_it() {
let help = invoke(&["run", "--help"]).stdout;
assert_eq!(assert_help(&invoke(&["run", "--bounce", "--help"])), help);
assert_eq!(assert_help(&invoke(&["run", "--bogus", "--help"])), help);
assert!(assert_help(&invoke(&["convert", "--rate", "-h"])).contains("Usage: soundcraft-cli convert"));
assert!(assert_help(&invoke(&["mcp", "--connect", "--help"])).contains("Usage: soundcraft-cli mcp"));
// After `--`, `--help` is a value.
assert_eq!(invoke(&["info", "--", "--help"]).code, 1);
}
#[test]
fn run_and_script_value_flags_without_a_value_are_usage_errors() {
for (flag, name) in [("--cmd", "ID[=JSON]"), ("--in", "S.scraft"), ("--save", "S.scraft"), ("--bounce", "OUT"), ("--start", "T"), ("--end", "T")]
{
let message = format!("a value is required for '{flag} <{name}>' but none was supplied");
assert_usage_error(&invoke(&["run", flag]), &message);
if flag != "--cmd" {
assert_usage_error(&invoke(&["script", "s.txt", flag]), &message);
}
}
// A following flag is not the value.
assert_usage_error(&invoke(&["run", "--bounce", "--inspect"]), "a value is required for '--bounce <OUT>'");
assert_usage_error(&invoke(&["script", "s.txt", "--demo", "--bounce"]), "a value is required for '--bounce <OUT>'");
}
#[test]
fn boolean_flags_reject_an_inline_value() {
assert_usage_error(&invoke(&["run", "--demo=1"]), "unexpected value '1' for '--demo' found; no more were expected");
assert_usage_error(&invoke(&["run", "--inspect=1"]), "unexpected value '1' for '--inspect' found; no more were expected");
assert_usage_error(&invoke(&["script", "s.txt", "--keep-going=1"]), "unexpected value '1' for '--keep-going' found; no more were expected");
assert_usage_error(&invoke(&["mcp", "--demo=1"]), "unexpected value '1' for '--demo' found; no more were expected");
}
#[test]
fn missing_and_extra_positionals_are_usage_errors() {
assert_usage_error(&invoke(&["info"]), "the following required arguments were not provided:\n <FILE>");
assert_usage_error(&invoke(&["convert", "in.wav"]), "the following required arguments were not provided:\n <OUT>");
assert_usage_error(&invoke(&["script"]), "the following required arguments were not provided:\n <FILE>");
assert_usage_error(&invoke(&["describe"]), "the following required arguments were not provided:\n <ID>");
assert_usage_error(&invoke(&["app", "--port", "1"]), "the following required arguments were not provided:\n <METHOD>");
for args in [
&["info", "a", "b"][..],
&["convert", "in.wav", "out.wav", "b"],
&["script", "s.txt", "b"],
&["run", "b"],
&["commands", "bounce", "b"],
&["describe", "file.bounce_mix", "b"],
&["app", "--port", "1", "session.inspect", "{}", "b"],
&["mcp", "b"],
&["parity", "b"],
&["plugins", "b"],
] {
assert_usage_error(&invoke(args), "unexpected argument 'b' found");
}
}
#[test]
fn unknown_flags_are_usage_errors_on_every_subcommand() {
for args in [
&["info", "in.wav", "--bogus"][..],
&["convert", "in.wav", "out.wav", "--bogus"],
&["run", "--bogus"],
&["script", "s.txt", "--bogus"],
&["commands", "--bogus"],
&["describe", "--bogus"],
&["app", "--bogus", "session.inspect"],
&["mcp", "--bogus"],
&["parity", "--bogus"],
&["plugins", "--bogus"],
] {
assert_usage_error(&invoke(args), "unexpected argument '--bogus' found");
}
// A single-dash word is a short flag, and there are none but -h.
assert_usage_error(&invoke(&["run", "-x"]), "unexpected argument '-x' found");
assert_usage_error(&invoke(&["mcp", "--automation-bogus=x"]), "unexpected argument '--automation-bogus' found");
}
/// `args_os`: text that isn't valid Unicode reaches clap instead of aborting the process.
#[cfg(unix)]
#[test]
fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let code = run([OsString::from("describe"), OsString::from_vec(b"\xe9.wav".to_vec())], &mut stdout, &mut stderr);
assert_eq!((code, stdout.as_slice()), (2, &b""[..]));
assert!(String::from_utf8_lossy(&stderr).starts_with("error: invalid UTF-8 was detected in one or more arguments"));
}
/// A bounce or save path goes to the engine as JSON text: a non-Unicode one fails before any command runs.
#[cfg(unix)]
#[test]
fn a_non_unicode_output_fails_before_the_commands_run() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
for flag in ["--bounce", "--save"] {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let args = ["run", "--cmd", "track.new", flag].map(OsString::from);
let code = run(args.into_iter().chain([OsString::from_vec(b"\xe9.out".to_vec())]), &mut stdout, &mut stderr);
assert_eq!((code, stdout.as_slice()), (1, &b""[..]), "{flag}: {}", String::from_utf8_lossy(&stderr));
}
}
#[test]
fn run_demo_and_sample_are_the_same_flag() {
let demo = invoke(&["run", "--demo", "--inspect"]);
let sample = invoke(&["run", "--sample", "--inspect"]);
assert_eq!((demo.code, sample.code), (0, 0));
assert_eq!(demo.stdout, sample.stdout);
assert!(demo.stdout.contains("\"name\": \"Verb\""), "{}", demo.stdout);
}
/// `--compact` prints the same JSON on one line.
#[track_caller]
fn assert_compact_matches_pretty(pretty: &[&str], compact: &[&str]) {
let (p, c) = (invoke(pretty), invoke(compact));
assert_eq!((p.code, c.code), (0, 0), "{} {}", p.stderr, c.stderr);
assert!(p.stdout.trim_end().contains('\n'), "pretty form spans lines: {}", p.stdout);
assert_eq!(c.stdout.trim_end().lines().count(), 1, "{}", c.stdout);
assert!(c.stdout.ends_with('\n'));
let (p, c): (serde_json::Value, serde_json::Value) = (serde_json::from_str(&p.stdout).unwrap(), serde_json::from_str(&c.stdout).unwrap());
assert_eq!(p, c);
}
#[test]
fn compact_prints_describe_and_run_inspect_on_one_line() {
assert_compact_matches_pretty(&["describe", "file.bounce_mix"], &["describe", "file.bounce_mix", "--compact"]);
assert_compact_matches_pretty(&["run", "--demo", "--inspect"], &["run", "--demo", "--inspect", "--compact"]);
}
#[test]
fn compact_without_inspect_is_a_usage_error() {
assert_usage_error(&invoke(&["run", "--compact"]), "the following required arguments were not provided:\n --inspect");
}
#[test]
fn run_command_failures() {
assert_fails(&invoke(&["run", "--cmd", "nosuch.cmd"]), "nosuch.cmd: unknown command `nosuch.cmd`");
assert_fails(&invoke(&["run", "--in", "nope.scraft"]), &format!("nope.scraft: {}", not_found()));
assert_fails(&invoke(&["run", "--in=nope.scraft"]), &format!("nope.scraft: {}", not_found()));
}
/// Relations and typed values are checked by clap: exit 2, the message names the flag, and nothing runs.
#[test]
fn flag_relations_and_typed_values_are_usage_errors() {
for (args, message) in [
(&["convert", "in.wav", "o.wav", "--dither", "--no-dither"][..], "the argument '--dither' cannot be used with '--no-dither'"),
(&["convert", "in.wav", "o.wav", "--rate", "0"], "invalid value '0' for '--rate <HZ>'"),
(&["convert", "in.wav", "o.wav", "--rate=abc"], "invalid value 'abc' for '--rate <HZ>'"),
(&["convert", "in.wav", "o.wav", "--bit-depth", "8"], "invalid value '8' for '--bit-depth <16|24|32f>'"),
(&["run", "--in", "s.scraft", "--demo"], "the argument '--in <S.scraft>' cannot be used with '--demo'"),
(&["script", "s.txt", "--in", "s.scraft", "--sample"], "the argument '--in <S.scraft>' cannot be used with '--demo'"),
(&["run", "--start", "1"], "the following required arguments were not provided:\n --bounce <OUT>"),
(&["script", "s.txt", "--end", "1"], "the following required arguments were not provided:\n --bounce <OUT>"),
(&["run", "--bounce", "b.wav", "--start", ""], "invalid value '' for '--start <T>'"),
(&["run", "--bounce", "b.wav", "--start", "nan"], "invalid value 'nan' for '--start <T>': expected a finite number"),
(&["run", "--bounce", "b.wav", "--end", "inf"], "invalid value 'inf' for '--end <T>': expected a finite number"),
(&["run", "--bounce", "b.wav", "--end", "1e999"], "invalid value '1e999' for '--end <T>': expected a finite number"),
(&["run", "--cmd", "track.new={", "--bounce", "b.wav"], "invalid value 'track.new={' for '--cmd <ID[=JSON]>': bad JSON:"),
(&["run", "--cmd", "=1"], "invalid value '=1' for '--cmd <ID[=JSON]>'"),
(&["describe", ""], "a value is required for '<ID>'"),
(&["commands", ""], "a value is required for '[FILTER]'"),
(&["app", ""], "a value is required for '<METHOD>'"),
] {
let got = invoke(args);
assert_usage_error(&got, message);
}
}
#[test]
fn a_bad_later_cmd_runs_none_of_them() {
assert_usage_error(&invoke(&["run", "--cmd", "track.new", "--cmd", "track.new={"]), "invalid value 'track.new={'");
}
#[test]
fn commands_filters_by_id_or_label() {
let all = invoke(&["commands"]);
assert_eq!((all.code, all.stderr.as_str()), (0, ""));
assert!(all.stdout.starts_with("session.new "), "{}", all.stdout);
let bounce = invoke(&["commands", "bounce"]);
assert!(bounce.stdout.contains("file.bounce_mix ") && !bounce.stdout.contains("session.new"), "{}", bounce.stdout);
// Matching is case-insensitive on the label only: an upper-case filter finds the labels, not the ids.
assert!(invoke(&["commands", "BOUNCE"]).stdout.contains("Bounce Mix..."));
assert_silent_success(&invoke(&["commands", "zzzzqq"]));
}
#[test]
fn describe_prints_json_for_a_known_id_and_fails_for_an_unknown_one() {
let got = invoke(&["describe", "file.bounce_mix"]);
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
assert!(got.stdout.starts_with("{\n \"id\": \"file.bounce_mix\",\n \"label\": \"Bounce Mix...\""), "{}", got.stdout);
assert_fails(&invoke(&["describe", "nosuch"]), "unknown command `nosuch`");
}
#[test]
fn plugins_lists_one_line_per_plugin() {
let got = invoke(&["plugins"]);
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
assert!(got.stdout.starts_with("eq_1band EQ 1-Band Eq\n"), "{}", got.stdout);
}
#[test]
fn app_reports_a_refused_connection_and_checks_arguments_first() {
for args in [
vec!["app", "--port", "1", "session.inspect"],
vec!["app", "--port=1", "session.inspect"],
vec!["app", "session.inspect", "--port", "1"],
vec!["app", "--port", "1", "session.inspect", "{}"],
] {
assert_refused(&invoke(&args), 1);
}
assert_usage_error(
&invoke(&["app", "--port", "1", "session.inspect", "{bad"]),
"invalid value '{bad' for '[JSON]': bad JSON: key must be a string at line 1 column 2",
);
for bad in ["abc", "", "70000", ":80", "host:", "host:abc"] {
assert_usage_error(
&invoke(&["app", "--port", bad, "session.inspect"]),
&format!("invalid value '{bad}' for '--port <P>': expected a port, 0-65535, or host:port"),
);
assert_usage_error(&invoke(&["mcp", "--connect", bad]), &format!("invalid value '{bad}' for '--connect <PORT>': expected a port"));
}
}
#[test]
fn mcp_argument_errors_fail_before_reading_stdin() {
assert_usage_error(&invoke(&["mcp", "--connect"]), "a value is required for '--connect <PORT>' but none was supplied");
assert_usage_error(&invoke(&["mcp", "--connect", "--demo"]), "a value is required for '--connect <PORT>' but none was supplied");
for flag in ["--automation-read-root", "--automation-write-root"] {
let message = format!("a value is required for '{flag} <DIR>' but none was supplied");
assert_usage_error(&invoke(&["mcp", flag]), &message);
assert_usage_error(&invoke(&["mcp", flag, "--demo"]), &message);
assert_usage_error(&invoke(&["mcp", &format!("{flag}=")]), &message);
}
assert_fails(
&invoke(&["mcp", "--automation-read-root", "missing-dir"]),
&format!("cannot open the automation read root `missing-dir`: {}", not_found()),
);
assert_usage_error(
&invoke(&["mcp", "--automation-read-root", ".", "--connect", "1"]),
"the argument '--automation-read-root <DIR>' cannot be used with '--connect <PORT>'",
);
}
+138
View File
@@ -0,0 +1,138 @@
//! Unknown options and missing option values must be reported, not silently skipped (#549).
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn bin() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
cmd
}
fn run(args: &[&str]) -> Output {
bin().args(args).output().unwrap()
}
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-options-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A 0.1 s mono 48 kHz 16-bit WAV, written by hand so the test needs no fixture.
fn tone(dir: &Path) -> PathBuf {
let frames: u32 = 4800;
let data: Vec<u8> = (0..frames).flat_map(|_| [0x00, 0x10]).collect();
let (bytes, rate, ch, bits) = (data.len() as u32, 48_000u32, 1u16, 16u16);
let mut wav = Vec::new();
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(36 + bytes).to_le_bytes());
wav.extend_from_slice(b"WAVEfmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&ch.to_le_bytes());
wav.extend_from_slice(&rate.to_le_bytes());
wav.extend_from_slice(&(rate * u32::from(ch) * u32::from(bits) / 8).to_le_bytes());
wav.extend_from_slice(&(ch * bits / 8).to_le_bytes());
wav.extend_from_slice(&bits.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&bytes.to_le_bytes());
wav.extend_from_slice(&data);
let path = dir.join("tone.wav");
std::fs::write(&path, wav).unwrap();
path
}
fn sample_format(cli_info: &str) -> String {
serde_json::from_str::<serde_json::Value>(cli_info).unwrap()["sample_format"].as_str().unwrap().to_owned()
}
#[test]
fn convert_rejects_an_unknown_option_instead_of_writing_the_file() {
let dir = tmpdir("convert-unknown");
let input = tone(&dir);
let out = dir.join("out.wav");
for bad in ["--bitdepth", "--normalise", "--bogus"] {
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
assert!(!o.status.success(), "{bad} should fail");
assert!(
String::from_utf8_lossy(&o.stderr).contains(&format!("unexpected argument '{bad}' found")),
"{bad}: {}",
String::from_utf8_lossy(&o.stderr)
);
assert!(!out.exists(), "{bad} should not write the output");
}
}
#[test]
fn convert_rejects_an_option_whose_value_is_missing() {
let dir = tmpdir("convert-novalue");
let input = tone(&dir);
let out = dir.join("out.wav");
for bad in ["--rate", "--bit-depth"] {
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
assert!(!o.status.success(), "{bad} should fail");
assert!(
String::from_utf8_lossy(&o.stderr).contains(&format!("a value is required for '{bad} ")),
"{bad}: {}",
String::from_utf8_lossy(&o.stderr)
);
assert!(!out.exists(), "{bad} should not write the output");
}
}
#[test]
fn convert_accepts_the_inline_value_form() {
let dir = tmpdir("convert-inline");
let input = tone(&dir);
let out = dir.join("out.wav");
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth=32f"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let info = run(&["info", out.to_str().unwrap()]);
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Float32");
}
#[test]
fn convert_still_accepts_the_documented_space_separated_form() {
let dir = tmpdir("convert-space");
let input = tone(&dir);
let out = dir.join("out.wav");
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth", "16"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let info = run(&["info", out.to_str().unwrap()]);
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Int16");
}
#[test]
fn run_rejects_a_misspelled_or_value_less_bounce() {
let dir = tmpdir("run-bounce");
let out = dir.join("x.wav");
let o = run(&["run", "--cmd", "session.new", "--bounce"]);
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("a value is required for '--bounce <OUT>'"), "{}", String::from_utf8_lossy(&o.stderr));
let o = run(&["run", "--cmd", "session.new", "--bounces", out.to_str().unwrap()]);
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("unexpected argument '--bounces' found"), "{}", String::from_utf8_lossy(&o.stderr));
assert!(!out.exists(), "nothing should be bounced");
}
#[test]
fn run_bounces_with_the_inline_value_form() {
let dir = tmpdir("run-inline");
let out = dir.join("y.wav");
let o = run(&["run", "--cmd", "session.new", &format!("--bounce={}", out.display())]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
assert!(out.exists(), "--bounce=PATH should write the file");
}
#[test]
fn run_executes_the_inline_cmd_form() {
let o = run(&["run", "--cmd=track.new", "--cmd", "track.new"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let stdout = String::from_utf8_lossy(&o.stdout);
assert_eq!(stdout.lines().filter(|l| l.starts_with("track.new: ")).count(), 2, "both --cmd forms should run: {stdout}");
}
+8 -3
View File
@@ -15,12 +15,17 @@ path = "src/main.rs"
[target.'cfg(target_arch = "wasm32")'.dependencies]
soundcraft-engine = { workspace = true }
soundcraft-ui-egui = { workspace = true }
soundcraft-audio-io.workspace = true
soundcraft-ui-egui.workspace = true
soundcraft-playback = { workspace = true }
egui = { workspace = true }
soundcraft-model = { workspace = true }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
log = { workspace = true }
serde_json = { workspace = true }
rfd = { workspace = true }
js-sys = "0.3"
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] }
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SoundCraft</title>
<link data-trunk rel="icon" type="image/png" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" />
<link data-trunk rel="rust" data-bin="soundcraft-web" data-wasm-opt="z" data-weak-refs />
+4 -1
View File
@@ -7,7 +7,10 @@
//!
//! Differences from the desktop app:
//! - no TCP control channel (browsers can't listen on sockets);
//! - no native file dialogs: the demo session is loaded on start;
//! - no file system: File > Open Session…, Import > Audio…/MIDI… and files dropped on the page are
//! read as bytes through the browser's file picker; a session is saved as ONE bundle file
//! (`<name>.scraftz`: the `.scraft` and its `Audio Files/`), and every save or export arrives as a
//! download (the demo session is still loaded on start);
//! - browsers only start audio after a user gesture, so the first click may be needed before
//! playback is heard.
//!
+192 -7
View File
@@ -1,7 +1,12 @@
//! The browser shell: the eframe web runner around [`SoundApp`].
use soundcraft_engine::Engine;
use soundcraft_audio_io::FileFormat;
use soundcraft_engine::{Engine, io};
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::sampler_window::SampleRead;
use soundcraft_ui_egui::{Services, SoundApp};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use wasm_bindgen::JsCast as _;
@@ -10,6 +15,7 @@ const LOADING_ID: &str = "soundcraft_loading";
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
io::set_deliver(download);
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
@@ -36,7 +42,7 @@ pub fn start() {
let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };
let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone()));
let app = SoundApp::new(engine, Some(player), Services::default());
Ok(Box::new(WebShell(app)))
Ok(Box::new(WebShell { app, inbox: Rc::default(), samples: Rc::default() }))
}),
)
.await;
@@ -53,18 +59,197 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
struct WebShell(SoundApp);
/// What a file read from the browser is for.
#[derive(Debug, Clone, Copy)]
enum Purpose {
Session,
Audio,
Midi,
}
const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION];
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
impl Purpose {
/// A dropped file goes where its extension says: sessions open, MIDI and audio import.
fn of(name: &str) -> Self {
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).unwrap_or_default();
if SESSION_EXTS.contains(&ext.as_str()) {
Self::Session
} else if MIDI_EXTS.contains(&ext.as_str()) {
Self::Midi
} else {
Self::Audio
}
}
/// The command whose path prompt this replaces in the browser.
fn for_command(cmd: &str) -> Option<Self> {
match cmd {
"session.open" => Some(Self::Session),
"file.import_audio" => Some(Self::Audio),
"file.import_midi" => Some(Self::Midi),
_ => None,
}
}
fn filter(self) -> (&'static str, Vec<&'static str>) {
match self {
Self::Session => ("SoundCraft Session", SESSION_EXTS.to_vec()),
Self::Audio => ("Audio", FileFormat::readable_extensions()),
Self::Midi => ("MIDI", MIDI_EXTS.to_vec()),
}
}
}
/// Files picked or dropped, read asynchronously (browsers can only read files that way) and
/// handled on the next frame. The browser has no paths: files travel as bytes.
type Inbox = Rc<RefCell<Vec<(Purpose, String, Vec<u8>)>>>;
/// Audio dropped on an instrument track or a Sampler window, its bytes read, waiting for the
/// next frame to load into that track's sampler.
type SampleInbox = Rc<RefCell<Vec<(SampleRead, Result<Vec<u8>, String>)>>>;
struct WebShell {
app: SoundApp,
inbox: Inbox,
samples: SampleInbox,
}
impl WebShell {
/// File > Open Session… / Import > Audio… / MIDI… would show a text box for a path, which the
/// browser can't read (#63, #64). Show the browser's file picker instead.
fn take_path_prompt(&mut self, ctx: &egui::Context) {
let purpose = match &self.app.dialogs.open {
Some(Dialog::PathPrompt { cmd, .. }) => Purpose::for_command(cmd),
_ => None,
};
let Some(purpose) = purpose else { return };
self.app.dialogs.open = None;
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let (label, exts) = purpose.filter();
let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, &exts).pick_file().await else { return };
let bytes = file.read().await;
inbox.borrow_mut().push((purpose, file.file_name(), bytes));
ctx.request_repaint();
});
}
/// Read files dropped on the browser window asynchronously: audio for an instrument track or a Sampler goes to
/// that sampler, while other drops are classified by extension and handled on a later frame.
fn take_drops(&mut self, ctx: &egui::Context) {
// In the browser a dropped file's `path()` is only its name: read its bytes instead.
// Audio dropped on an instrument track or a Sampler window goes to its sampler instead.
let dropped = self.app.take_dropped_files();
for read in self.app.take_sample_reads() {
let (samples, ctx) = (self.samples.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let bytes = read.file.bytes_async().await;
samples.borrow_mut().push((read, bytes));
ctx.request_repaint();
});
}
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into());
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
match f.bytes_async().await {
Ok(bytes) => {
inbox.borrow_mut().push((Purpose::of(&name), name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
fn handle(&mut self, purpose: Purpose, name: String, bytes: Vec<u8>) {
let e = &mut self.app.engine;
let at = e.session().edit.selection.start.max(0);
let result = match purpose {
// Asks first when the open session has changes: the bytes wait for the answer.
Purpose::Session => return soundcraft_ui_egui::dialogs::open_session_bytes(&mut self.app, name, bytes),
// Imports are edits: one undo step each, like the import commands.
Purpose::Audio => {
let before = e.session_arc();
io::import_audio_bytes(e, &name, &bytes, None, None, at).map(|_| {
e.push_undo("Import Audio", before);
format!("Imported {name}")
})
}
Purpose::Midi => {
let before = e.session_arc();
io::import_midi_bytes(e, &bytes, at, true).map(|_| {
e.push_undo("Import MIDI", before);
format!("Imported {name}")
})
}
};
self.app.ui.status = result.unwrap_or_else(|err| err.to_string());
}
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.0.logic(ctx);
self.take_drops(ctx);
self.take_path_prompt(ctx);
let pending = std::mem::take(&mut *self.inbox.borrow_mut());
for (purpose, name, bytes) in pending {
self.handle(purpose, name, bytes);
}
let samples = std::mem::take(&mut *self.samples.borrow_mut());
for (read, bytes) in samples {
self.app.load_sample_bytes(&read, bytes);
}
self.app.logic(ctx);
// A prompt opened during this frame becomes a picker on the next one.
self.take_path_prompt(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
/// Forward raw input to the shared app hook so scripted gestures and host-key protection behave like the desktop
/// shell.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.app.ui(ui);
}
}
/// Hand a saved or exported file to the person as a download named `name` (the engine's delivery
/// hook on the web).
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("download failed: {e:?}");
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase());
opts.set_type(match ext.as_deref() {
Some("scraftz") => "application/zip",
Some("mid" | "midi") => "audio/midi",
Some("svg") => "image/svg+xml",
Some("txt") => "text/plain",
Some("json" | "scraft") => "application/json",
Some("musicxml") => "application/vnd.recordare.musicxml+xml",
ext => ext.and_then(FileFormat::from_extension).map_or("application/octet-stream", FileFormat::mime_type),
});
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(name);
a.style().set_property("display", "none").map_err(js)?;
document.body().ok_or("no body")?.append_child(&a).map_err(js)?;
a.click();
a.remove();
// Revoke after the click was dispatched; the download keeps its own reference.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
+19 -3
View File
@@ -10,14 +10,25 @@ default-run = "soundcraft"
[lints]
workspace = true
[features]
# ASIO audio devices on Windows (see soundcraft-playback's `asio` feature).
asio = ["soundcraft-playback/asio"]
[dependencies]
soundcraft-engine.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-ui-egui.workspace = true
soundcraft-playback.workspace = true
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui.workspace = true
soundcraft-automation.workspace = true
clap.workspace = true
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "clipboard"] }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
serde_json.workspace = true
log.workspace = true
chrono.workspace = true
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true
@@ -27,4 +38,9 @@ rfd.workspace = true
winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.19.2", default-features = false }
muda = { version = "0.21.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+120 -33
View File
@@ -1,7 +1,14 @@
//! Per-user settings and automation profile directories.
use std::ffi::OsString;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use soundcraft_ui_egui::docking;
/// Portable mode (#268): this file beside the executable keeps settings, autosaves, presets and
/// logs in [`PORTABLE_DATA_DIR`] next to it instead of the user profile.
const PORTABLE_MARKER: &str = "portable.txt";
const PORTABLE_DATA_DIR: &str = "SoundCraftData";
#[derive(Clone, Copy)]
enum Platform {
@@ -10,10 +17,13 @@ enum Platform {
Other,
}
/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default.
/// The override is the complete directory; no application-name suffix is appended. An explicitly
/// empty value disables the settings directory instead of falling back to a personal profile.
pub fn config_dir() -> Option<PathBuf> {
/// SoundCraft's settings folder, resolved from the variable lookup `get` (see `Env::from_lookup`).
/// A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default and is the complete
/// directory; no application-name suffix is appended. An explicitly empty value means no settings
/// folder instead of a fallback to a personal profile (the documented exception to RFC-0012 rule
/// 1.7). Otherwise `portable.txt` beside the executable selects `<exe dir>/SoundCraftData`, when
/// that folder can be written (a read-only medium falls back to the platform default).
pub fn resolve_config_dir(get: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let platform = if cfg!(target_os = "macos") {
Platform::Mac
} else if cfg!(windows) {
@@ -21,13 +31,21 @@ pub fn config_dir() -> Option<PathBuf> {
} else {
Platform::Other
};
config_dir_with(platform, |name| std::env::var_os(name))
let exe = std::env::current_exe().ok();
config_dir_with(platform, get, exe.as_deref().and_then(Path::parent))
}
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") {
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> Option<PathBuf> {
if let Some(dir) = env(crate::env::CONFIG_DIR) {
return (!dir.is_empty()).then(|| PathBuf::from(dir));
}
if let Some(exe_dir) = exe_dir.filter(|d| d.join(PORTABLE_MARKER).is_file()) {
let data = exe_dir.join(PORTABLE_DATA_DIR);
match writable(&data) {
Ok(()) => return Some(data),
Err(e) => log::warn!("portable mode: {} can't be written ({e}); using the user profile instead", data.display()),
}
}
match platform {
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")),
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")),
@@ -37,6 +55,14 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -
}
}
/// Create `dir` if needed and prove a file can be written in it.
fn writable(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let probe = dir.join(format!(".write-test-{}", std::process::id()));
std::fs::write(&probe, b"")?;
std::fs::remove_file(probe)
}
/// Read bounded settings, recovering unreadable docking metadata independently of other settings.
pub fn load_ui(path: &std::path::Path) -> Result<Option<soundcraft_ui_egui::UiState>, String> {
read_ui(path, true)
@@ -55,21 +81,21 @@ fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result<Option<sound
return Err("Settings file exceeds the 4 MiB limit".into());
}
let mut saved: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?;
docking::reset_old_layouts(&mut saved);
if recover_docking && let Some(fields) = saved.as_object_mut() {
for (field, mix) in [("docking", false), ("mix_docking", true)] {
let unreadable =
fields.get(field).is_some_and(|value| match serde_json::from_value::<soundcraft_ui_egui::docking::Workspace>(value.clone()) {
Ok(workspace) => if mix { workspace.validate_mix() } else { workspace.validate() }.is_err(),
Err(_) => true,
});
let unreadable = fields.get(field).is_some_and(|value| match serde_json::from_value::<docking::Workspace>(value.clone()) {
Ok(workspace) => if mix { docking::validate_mix(&workspace) } else { docking::validate(&workspace) }.is_err(),
Err(_) => true,
});
if unreadable {
fields.remove(field);
}
}
}
let state: soundcraft_ui_egui::UiState = serde_json::from_value(saved).map_err(|error| format!("Settings could not be restored: {error}"))?;
state.docking.validate()?;
state.mix_docking.validate_mix()?;
docking::validate(&state.docking)?;
docking::validate_mix(&state.mix_docking)?;
Ok(Some(state))
}
@@ -80,11 +106,12 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu
use std::io::Write as _;
// Loading can recover a future layout for this session, but saving must preserve its source.
read_ui(path, false)?;
ui.docking.validate()?;
ui.mix_docking.validate_mix()?;
docking::validate(&ui.docking)?;
docking::validate_mix(&ui.mix_docking)?;
let mut saved = ui.clone();
saved.plugin_windows.clear();
saved.status.clear();
saved.docking_version = docking::LAYOUT_VERSION;
let bytes = serde_json::to_vec_pretty(&saved).map_err(|error| error.to_string())?;
if bytes.len() as u64 > MAX_UI_BYTES {
return Err("Settings exceed the 4 MiB limit".into());
@@ -129,18 +156,18 @@ mod config_tests {
app.ui = load_ui(&path).unwrap().unwrap();
assert_eq!(app.ui.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), edit);
assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
app.ui.window = MainWindow::Edit;
app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap();
assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
let mut invalid_mix = app.ui.clone();
invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default();
invalid_mix.mix_docking = soundcraft_ui_egui::docking::edit_default();
let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap();
std::fs::write(&path, &invalid_bytes).unwrap();
let recovered = load_ui(&path).unwrap().unwrap();
assert_eq!(recovered.theme, app.ui.theme);
assert_eq!(serde_json::to_value(recovered.docking).unwrap(), serde_json::to_value(&app.ui.docking).unwrap());
recovered.mix_docking.validate_mix().unwrap();
soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
assert!(save_ui(&path, &app.ui).is_err());
assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes);
std::fs::write(&path, b"damaged settings").unwrap();
@@ -170,8 +197,8 @@ mod config_tests {
let recovered = load_ui(&path).unwrap().unwrap();
assert_eq!(recovered.theme, app.ui.theme);
assert!(recovered.narrow_mix);
recovered.docking.validate().unwrap();
recovered.mix_docking.validate_mix().unwrap();
soundcraft_ui_egui::docking::validate(&recovered.docking).unwrap();
soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
let other = if field == "docking" { "mix_docking" } else { "docking" };
assert_eq!(serde_json::to_value(&recovered).unwrap()[other], saved[other]);
assert!(save_ui(&path, &recovered).is_err());
@@ -181,6 +208,35 @@ mod config_tests {
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn settings_from_before_dock_columns_load_with_the_default_layouts() {
let directory = std::env::temp_dir().join(format!("soundcraft-split-frame-prefs-{}", std::process::id()));
std::fs::create_dir_all(&directory).unwrap();
let path = directory.join("ui.json");
let split_frames = serde_json::json!({"layout": {"root": {"Split": {
"axis": "Horizontal",
"size": {"FixedFirst": 200.0},
"first": {"Tabs": {"panels": ["tracks"], "active": 0}},
"second": {"Tabs": {"panels": ["canvas"], "active": 0}}
}}, "floating": []}});
let ui = soundcraft_ui_egui::UiState { theme: soundcraft_ui_egui::theme::ThemeMode::Light, narrow_mix: true, ..Default::default() };
let mut saved = serde_json::to_value(&ui).unwrap();
let fields = saved.as_object_mut().unwrap();
fields.remove("docking_version");
fields.insert("docking".into(), split_frames.clone());
fields.insert("mix_docking".into(), split_frames);
std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap();
let loaded = load_ui(&path).unwrap().unwrap();
assert_eq!(loaded.docking, docking::edit_default());
assert_eq!(loaded.mix_docking, docking::mix_default());
assert_eq!(loaded.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
assert!(loaded.narrow_mix);
save_ui(&path, &loaded).unwrap();
let written: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(written["docking_version"], serde_json::json!(docking::LAYOUT_VERSION));
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn oversized_settings_are_rejected_and_preserved() {
let directory = std::env::temp_dir().join(format!("soundcraft-bounded-prefs-{}", std::process::id()));
@@ -195,16 +251,20 @@ mod config_tests {
}
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), None)
}
#[test]
fn config_override_wins_without_reading_user_profile() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
let actual = config_dir_with(platform, |name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::from("isolated/settings"))
});
let actual = config_dir_with(
platform,
|name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::from("isolated/settings"))
},
None,
);
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
}
}
@@ -213,10 +273,14 @@ mod config_tests {
fn config_empty_override_disables_profile_without_reading_defaults() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
assert_eq!(
config_dir_with(platform, |name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::new())
}),
config_dir_with(
platform,
|name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::new())
},
None
),
None
);
}
@@ -234,11 +298,34 @@ mod config_tests {
}
}
#[test]
fn portable_txt_beside_the_exe_keeps_settings_next_to_it() {
let exe = std::env::temp_dir().join(format!("soundcraft-portable-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&exe);
std::fs::create_dir_all(&exe).unwrap();
let vars = [("APPDATA", "/roaming")];
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")), "no marker: the profile");
std::fs::write(exe.join("portable.txt"), b"").unwrap();
assert_eq!(resolve_with_exe(&vars, &exe), Some(exe.join("SoundCraftData")));
assert!(exe.join("SoundCraftData").is_dir());
// The explicit override still wins.
assert_eq!(resolve_with_exe(&[("SOUNDCRAFT_CONFIG_DIR", "/custom")], &exe), Some(PathBuf::from("/custom")));
// A data folder that can't be made (a file is in the way) falls back to the profile.
std::fs::remove_dir_all(exe.join("SoundCraftData")).unwrap();
std::fs::write(exe.join("SoundCraftData"), b"").unwrap();
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")));
std::fs::remove_dir_all(exe).unwrap();
}
fn resolve_with_exe(vars: &[(&str, &str)], exe: &Path) -> Option<PathBuf> {
config_dir_with(Platform::Windows, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), Some(exe))
}
#[cfg(unix)]
#[test]
fn config_override_preserves_non_unicode_paths() {
use std::os::unix::ffi::OsStringExt as _;
let value = OsString::from_vec(b"isolated/\xff".to_vec());
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone()), None), Some(PathBuf::from(value)));
}
}
+185 -22
View File
@@ -1,15 +1,36 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`soundcraft-cli mcp --connect PORT`) wraps.
//!
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
use serde_json::{Value, json};
use soundcraft_ui_egui::ControlRequest;
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::time::Duration;
/// Longest accepted request line (bytes, without the newline). The largest payload a request
/// carries is a plugin state for `mix.insert_state`: base64 of at most `b64::MAX_DECODED_BYTES`
/// (64 MiB, so about 85 MiB of text). Room for that and 1 MiB of JSON around it; replies
/// (screenshots) aren't bounded by this.
const MAX_LINE: usize = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_ceil(3) * 4 + (1 << 20);
// With MAX_CONNECTIONS, at most 1.5 GiB of request lines are buffered at once.
const _: () = assert!(MAX_LINE <= 96 << 20);
/// Connections served at once; further ones get an error line and are closed.
const MAX_CONNECTIONS: usize = 16;
/// Requests that may wait for the UI thread. Each connection handles one request at a time, so
/// this matches [`MAX_CONNECTIONS`] and makes the transport's bound explicit instead of relying
/// on an unbounded channel.
const MAX_QUEUED_REQUESTS: usize = MAX_CONNECTIONS;
/// Bind the loopback control listener and return the request receiver. Port 0 picks a free one and the actual port is
/// printed; bind or spawn failures are logged instead of panicking.
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let (tx, rx) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -20,42 +41,107 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port);
log::info!("control server listening on 127.0.0.1:{actual}");
println!("SOUNDCRAFT_CONTROL_PORT={actual}");
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || serve(stream, tx, ctx));
}
});
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx));
if let Err(e) = spawned {
log::error!("control thread failed: {e}");
}
rx
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
/// its thread can't be started, or panics).
struct Slot(Arc<AtomicUsize>);
impl Drop for Slot {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
/// Accept until the listener closes, admitting at most [`MAX_CONNECTIONS`] peers; excess peers get one error line.
/// Each admitted connection is served on its own thread, releasing its slot on exit.
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue;
}
let slot = Slot(Arc::clone(&open));
let tx = tx.clone();
let ctx = ctx.clone();
let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || {
let _slot = slot;
serve(stream, tx, ctx);
});
}
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
/// Answer newline-delimited JSON requests for one connection: bound each line, forward it to the UI thread with a 90
/// s reply deadline, and stop at EOF, invalid UTF-8 or a closed request queue. Bad JSON still gets an error reply.
fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(true) => {}
Ok(false) => break,
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
break;
}
}
// Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did.
let Ok(line) = std::str::from_utf8(&buf) else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
let reply = match serde_json::from_str::<Value>(line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
match tx.try_send(req) {
Ok(()) => {
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(90)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
// Refuse rather than grow or block: the UI drains a bounded batch each tick.
Err(TrySendError::Full(_)) => json!({"ok": false, "id": id, "error": "control queue full"}),
Err(TrySendError::Disconnected(_)) => break,
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(90)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
@@ -64,3 +150,80 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lines_are_read_up_to_the_limit() {
let mut buf = Vec::new();
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"bb", "CRLF is stripped");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"cc", "a last line without a newline is still read");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream");
}
#[test]
fn an_overlong_line_is_refused_without_reading_the_rest() {
let mut buf = Vec::new();
let long = [b'x'; 100];
let mut input: &[u8] = &long;
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
#[test]
fn the_largest_plugin_state_fits_in_a_line() {
let state = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_ceil(3) * 4;
let request = json!({"id": 1, "method": "engine.execute", "params": {"command": "mix.insert_state", "params": {"track": "Audio 1", "slot": 0, "state": ""}}});
assert!(state + request.to_string().len() <= MAX_LINE);
}
/// The control server on a loopback port, with a stand-in UI that echoes each method.
fn serve_echo() -> std::net::SocketAddr {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
}
});
addr
}
#[test]
fn requests_are_answered_with_their_ids() {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
let mut r = BufReader::new(c);
for (id, method) in [(3, "ping"), (4, "pong")] {
let mut line = String::new();
r.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!((v["id"].as_i64(), v["method"].as_str()), (Some(id), Some(method)), "{line}");
}
}
#[test]
fn extra_connections_are_refused() {
let addr = serve_echo();
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..MAX_CONNECTIONS).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut line = String::new();
BufReader::new(&mut extra).read_line(&mut line).unwrap();
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
}
+154
View File
@@ -0,0 +1,154 @@
//! The environment variables the app reads, read once at startup into [`Env`]. Library crates
//! never read these; `main` passes the values down. Platform variables (`HOME`, `DISPLAY`, ...)
//! and test-only ones stay with the code that needs them.
//!
//! `RUST_LOG` is not a field: the logger reads it ([`rust_log`]) before anything else, so that
//! the warnings [`Switch`] logs for a malformed value reach the log file.
use std::ffi::OsString;
use std::path::PathBuf;
use crate::switch::Switch;
pub(crate) const CONFIG_DIR: &str = "SOUNDCRAFT_CONFIG_DIR";
pub(crate) const NO_PREFS: &str = "SOUNDCRAFT_NO_PREFS";
pub(crate) const NO_SYSTEM_FONTS: &str = "SOUNDCRAFT_NO_SYSTEM_FONTS";
pub(crate) const WAYLAND: &str = "SOUNDCRAFT_WAYLAND";
pub(crate) const RUST_LOG: &str = "RUST_LOG";
/// The variables and their `--help` lines, in the order `--help` lists them.
pub(crate) const VARS: &[(&str, &str)] = &[
// Read by clap (`env =` on `--control`), not by `Env`.
("SOUNDCRAFT_CONTROL_PORT", "The --control fallback: a port or host:port, ignored for another host"),
(CONFIG_DIR, "The settings folder (ui.json, Autosave, Presets, logs); empty: none (unlike the others, where empty counts as unset)"),
(NO_PREFS, "1 doesn't read or write preferences and logs to stderr only; 0 or unset is off"),
(NO_SYSTEM_FONTS, "1 uses the built-in fonts only; 0 or unset is off"),
(WAYLAND, "Linux: 1 keeps the native Wayland backend instead of XWayland; 0 or unset is off"),
(RUST_LOG, "Log filter, env_logger style: debug, warn,soundcraft_mix=trace"),
];
/// The `--help` Environment section, generated from [`VARS`].
pub(crate) fn help() -> String {
let width = VARS.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
let mut out = String::from("Environment:\n");
for (name, line) in VARS {
out.push_str(&format!(" {name:<width$} {line}\n"));
}
out.push_str(
" The on/off variables take 1/on/true/yes or 0/off/false/no, in any case; any other value is\n ignored with a warning in the log.\n",
);
out.truncate(out.trim_end().len());
out
}
/// The app's variables, as read at startup.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Env {
/// The settings folder (ui.json, Autosave, Presets, logs), resolved once: `SOUNDCRAFT_CONFIG_DIR`,
/// else the portable data folder, else the platform's. `None` when the variable is set but
/// empty, or no platform folder is available. Every other variable treats empty as unset
/// (RFC-0012 rule 1.7); this one doesn't.
pub(crate) config_dir: Option<PathBuf>,
pub(crate) no_prefs: Switch,
pub(crate) no_system_fonts: Switch,
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
pub(crate) wayland: Switch,
}
impl Env {
/// Reads the process environment. Call after the logger is installed, so malformed switches are logged.
pub(crate) fn read() -> Env {
Env::from_lookup(|name| std::env::var_os(name))
}
pub(crate) fn from_lookup(get: impl Fn(&str) -> Option<OsString>) -> Env {
let switch = |name: &str| match get(name) {
None => Switch::Default,
Some(v) => Switch::parse(name, Some(&v.to_string_lossy())),
};
Env {
config_dir: crate::config::resolve_config_dir(&get),
no_prefs: switch(NO_PREFS),
no_system_fonts: switch(NO_SYSTEM_FONTS),
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
wayland: switch(WAYLAND),
}
}
/// Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
pub(crate) fn prefs_enabled(&self) -> bool {
self.no_prefs != Switch::On
}
pub(crate) fn system_fonts(&self) -> bool {
self.no_system_fonts != Switch::On
}
/// Whether to run through XWayland: winit has no file drag-and-drop on Wayland, so dropping
/// files from the file manager showed a "no" cursor. Needs a nonempty `DISPLAY`
/// (`display`); `SOUNDCRAFT_WAYLAND=1` keeps the native backend.
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
pub(crate) fn use_xwayland(&self, display: Option<&OsString>) -> bool {
display.is_some_and(|d| !d.is_empty()) && self.wayland != Switch::On
}
}
/// The `RUST_LOG` filter, `None` when unset or blank.
pub(crate) fn rust_log() -> Option<String> {
std::env::var(RUST_LOG).ok().filter(|s| !s.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
fn lookup(vars: &'static [(&'static str, &'static str)]) -> Env {
Env::from_lookup(|name| vars.iter().find(|(n, _)| *n == name).map(|(_, v)| OsString::from(v)))
}
#[test]
fn variables_are_read_into_typed_fields() {
let env = lookup(&[(CONFIG_DIR, "/c"), (NO_PREFS, "yes"), (NO_SYSTEM_FONTS, "0"), (WAYLAND, "bogus")]);
assert_eq!(env.config_dir, Some(PathBuf::from("/c")));
assert_eq!((env.no_prefs, env.no_system_fonts, env.wayland), (Switch::On, Switch::Off, Switch::Default));
}
#[test]
fn settings_folder_is_resolved_into_env() {
// Empty means no folder, even with a platform folder available.
assert_eq!(lookup(&[(CONFIG_DIR, ""), ("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")]).config_dir, None);
assert_eq!(lookup(&[(CONFIG_DIR, "/c"), ("HOME", "/h")]).config_dir, Some(PathBuf::from("/c")));
let unset = lookup(&[("HOME", "/h"), ("APPDATA", "/h"), ("XDG_CONFIG_HOME", "/x")]).config_dir;
assert!(unset.is_some_and(|d| d.ends_with("SoundCraft") || d.ends_with("soundcraft") || d.ends_with("SoundCraftData")));
// Disabling preferences doesn't depend on the folder.
let env = lookup(&[(CONFIG_DIR, "/c"), (NO_PREFS, "1")]);
assert!(env.config_dir.is_some() && !env.prefs_enabled());
}
#[test]
fn no_prefs_and_no_system_fonts_turn_features_off() {
assert!(Env::default().prefs_enabled() && Env::default().system_fonts());
let env = Env { no_prefs: Switch::On, no_system_fonts: Switch::On, ..Env::default() };
assert!(!env.prefs_enabled() && !env.system_fonts());
assert!(Env { no_prefs: Switch::Off, ..Env::default() }.prefs_enabled());
}
#[test]
fn xwayland_needs_a_display_and_wayland_not_forced() {
let display = OsString::from(":0");
assert!(Env::default().use_xwayland(Some(&display)));
assert!(Env { wayland: Switch::Off, ..Env::default() }.use_xwayland(Some(&display)));
assert!(!Env { wayland: Switch::On, ..Env::default() }.use_xwayland(Some(&display)));
assert!(!Env::default().use_xwayland(None));
assert!(!Env::default().use_xwayland(Some(&OsString::new())));
}
#[test]
fn help_lists_every_variable() {
let help = help();
for (name, line) in VARS {
assert!(help.contains(name) && help.contains(line), "{name} missing from\n{help}");
}
assert_eq!(help.matches("0/off/false/no").count(), 1, "{help}");
}
}
+51 -5
View File
@@ -1,10 +1,14 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, Dx12Compiler},
};
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit
/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>, compiler_override: Option<Dx12Compiler>) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
@@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
// workaround without mutating process environment variables.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
// 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 and loses the device before the first frame
// (issue #185). FXC uses the compiler that is part of Windows.
create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc);
log::info!(
"Windows graphics: backends={:?}, DX12 compiler={:?}",
create.instance_descriptor.backends,
create.instance_descriptor.backend_options.dx12.shader_compiler
);
}
}
@@ -21,7 +35,7 @@ mod tests {
fn configured_backends(backend_override: Option<Backends>) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
configure(&mut options, backend_override, None);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
@@ -38,6 +52,38 @@ mod tests {
assert_eq!(configured_backends(None), Backends::DX12);
}
#[test]
fn windows_default_uses_fxc_instead_of_searching_for_dxc() {
let mut options = NativeOptions::default();
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// Auto can find an unrelated DXC without its matching DXIL runtime (issue #185).
create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto;
}
configure(&mut options, None, None);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
}
#[test]
fn explicit_compiler_override_is_preserved() {
for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] {
let mut options = NativeOptions::default();
configure(&mut options, None, Some(compiler.clone()));
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) {
(Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {}
(Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => {
assert_eq!(actual, expected);
}
(expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"),
}
}
}
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
+223
View File
@@ -0,0 +1,223 @@
//! The desktop app's command line, as clap types: `soundcraft --help` comes from these doc
//! comments, so the help and the parser can't disagree. Anything they don't declare is a usage
//! error naming it (exit status 2), as is a bad `--control` or `SOUNDCRAFT_CONTROL_PORT`. A file
//! whose name starts with `-` goes after `--`. Another host's `host:port` is a usage error from
//! `--control` and ignored from the variable ([`control_port`]).
//!
//! The control flag is `soundcraft-automation`'s, shared with `soundcraft-cli`.
use std::ffi::OsString;
use std::path::PathBuf;
use clap::builder::StyledStr;
use clap::error::{ContextKind, ContextValue, ErrorKind};
use clap::parser::ValueSource;
use clap::{Command, CommandFactory, FromArgMatches, Parser};
use soundcraft_automation::ControlTarget;
use soundcraft_automation::args::{ControlServerArgs, blank_env_is_unset, name_env_vars};
/// Opens SoundCraft with the given files
#[derive(Parser)]
#[command(name = "soundcraft", version = soundcraft_automation::args::long_version(), after_help = crate::env::help())]
pub(crate) struct LaunchArgs {
/// Sessions, audio and MIDI files to open. Put `--` before a name that starts with `-`
pub(crate) files: Vec<PathBuf>,
/// Start with the demo session
#[arg(long, visible_alias = "sample")]
pub(crate) demo: bool,
/// Don't open an audio device
#[arg(long)]
pub(crate) no_audio: bool,
#[command(flatten)]
control: ControlServerArgs,
/// The port the control server starts on, from `control` ([`control_port`]).
#[arg(skip)]
pub(crate) control_port: Option<u16>,
}
/// The command line, where an empty or blank variable counts as unset.
pub(crate) fn command() -> Command {
LaunchArgs::command().mut_args(blank_env_is_unset)
}
/// Parses the launch arguments (without the program name) with `cmd`, from [`command`]. macOS
/// passes a process serial number (`-psn_…`) when an app is started from Finder on old versions:
/// it is dropped first, unless it comes after `--`. A bad value from a variable is a usage error
/// naming the variable.
pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator<Item = OsString>) -> Result<LaunchArgs, clap::Error> {
let mut options = true;
let args: Vec<OsString> = args
.into_iter()
.filter(|a| {
options &= a != "--";
!(options && a.to_str().is_some_and(|s| s.starts_with("-psn_")))
})
.collect();
let argv = std::iter::once(OsString::from("soundcraft")).chain(args.iter().cloned());
let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?;
let mut launch = LaunchArgs::from_arg_matches(&matches)?;
launch.control_port = control_port(launch.control.control.take(), matches.value_source("control")).map_err(|e| e.into_clap(&mut cmd))?;
Ok(launch)
}
/// The port the control server starts on, from `--control` or `SOUNDCRAFT_CONTROL_PORT`
/// (`source`). The server listens on 127.0.0.1 only, so it can't serve another host's
/// `host:port`. From the flag that is a usage error. The variable is `soundcraft-cli app`'s too and
/// may point it at another machine, so from there it is ignored with a warning and the app opens
/// without a control server.
fn control_port(value: Option<ControlTarget>, source: Option<ValueSource>) -> Result<Option<u16>, RemoteHost> {
match value {
None => Ok(None),
Some(target) if target.is_loopback() => Ok(Some(target.port)),
Some(target) if source == Some(ValueSource::EnvVariable) => {
log::warn!("SOUNDCRAFT_CONTROL_PORT={target} names another host, so no control server starts: it listens on 127.0.0.1 only");
Ok(None)
}
Some(target) => Err(RemoteHost(target)),
}
}
/// A `--control` `host:port` naming another host.
#[derive(Debug, PartialEq)]
struct RemoteHost(ControlTarget);
impl RemoteHost {
/// clap's invalid-value error for `--control`, with the reason as a tip.
fn into_clap(self, cmd: &mut Command) -> clap::Error {
let flag = cmd.get_arguments().find(|a| a.get_id() == "control").map_or_else(|| "--control".to_owned(), ToString::to_string);
let tip =
StyledStr::from("the control server listens on 127.0.0.1 only: give a port, or host:port with a loopback host (127.0.0.1, localhost)");
let mut error = clap::Error::new(ErrorKind::ValueValidation).with_cmd(cmd);
error.insert(ContextKind::InvalidArg, ContextValue::String(flag));
error.insert(ContextKind::InvalidValue, ContextValue::String(self.0.to_string()));
error.insert(ContextKind::Suggested, ContextValue::StyledStrs(vec![tip]));
error.insert(ContextKind::Usage, ContextValue::StyledStr(cmd.render_usage()));
error
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::error::ErrorKind;
/// Parse without the variable, so one exported where the tests run can't change them (the
/// process tests in `tests/launch_flags.rs` cover it).
fn parse_args(args: &[&str]) -> Result<LaunchArgs, clap::Error> {
parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from))
}
fn usage_error(args: &[&str]) -> String {
let Err(e) = parse_args(args) else { panic!("{args:?} parsed") };
assert_eq!(e.exit_code(), 2, "{args:?}: {e}");
e.to_string()
}
#[test]
fn command_is_consistent() {
command().debug_assert();
}
#[test]
fn flags_and_files_are_parsed() {
let a = parse_args(&["--demo", "--no-audio", "--control", "50494", "a.scraft", "b.wav"]).unwrap();
assert!(a.demo && a.no_audio);
assert_eq!(a.control_port, Some(50494));
assert_eq!(a.files, [PathBuf::from("a.scraft"), PathBuf::from("b.wav")]);
assert!(parse_args(&["--sample"]).unwrap().demo);
let none = parse_args(&[]).unwrap();
assert!(!none.demo && !none.no_audio && none.control_port.is_none() && none.files.is_empty());
}
#[test]
fn version_and_help_stop_parsing() {
for flag in ["--version", "-V"] {
let e = parse_args(&[flag, "--control"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0));
assert_eq!(e.to_string(), format!("soundcraft {}\n", soundcraft_automation::args::long_version()));
}
let e = parse_args(&["-h", "--bogus"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0));
let help = e.to_string();
for text in ["--demo", "--sample", "--no-audio", "--control <PORT>"] {
assert!(help.contains(text), "{text}: {help}");
}
let help = command().render_help().to_string();
assert!(help.contains("[env: SOUNDCRAFT_CONTROL_PORT"), "{help}");
for var in ["SOUNDCRAFT_CONFIG_DIR", "SOUNDCRAFT_NO_PREFS", "SOUNDCRAFT_NO_SYSTEM_FONTS", "SOUNDCRAFT_WAYLAND", "RUST_LOG"] {
assert!(help.contains(&format!("\n {var} ")), "{var}: {help}");
}
}
#[test]
fn a_bad_control_port_is_a_usage_error_naming_it() {
assert!(usage_error(&["--control", "78787"]).contains("invalid value '78787' for '--control <PORT>'"));
assert!(usage_error(&["--control", "nope"]).contains("invalid value 'nope'"));
assert!(usage_error(&["--control"]).contains("a value is required for '--control <PORT>'"));
assert!(usage_error(&["--control", "--demo"]).contains("a value is required for '--control <PORT>'"));
assert!(usage_error(&["--control", "-1"]).contains("invalid value '-1' for '--control <PORT>'"));
let remote = usage_error(&["--control", "10.0.0.5:7979"]);
assert!(
remote.starts_with("error: invalid value '10.0.0.5:7979' for '--control <PORT>'\n\n tip: the control server listens on 127.0.0.1 only"),
"{remote}"
);
assert!(remote.contains("\nUsage: soundcraft "), "{remote}");
assert!(usage_error(&["--control", "::1:7979"]).contains("invalid value '::1:7979' for '--control <PORT>'"));
}
/// The variable may point `soundcraft-cli app` at another machine: the app then opens without
/// a control server.
#[test]
fn another_host_is_ignored_only_from_the_variable() {
let remote = || ControlTarget { host: Some("10.0.0.5".to_owned()), port: 7979 };
assert_eq!(control_port(Some(remote()), Some(ValueSource::EnvVariable)), Ok(None));
assert_eq!(control_port(Some(remote()), Some(ValueSource::CommandLine)), Err(RemoteHost(remote())));
let local = ControlTarget { host: None, port: 7979 };
assert_eq!(control_port(Some(local), Some(ValueSource::EnvVariable)), Ok(Some(7979)));
assert_eq!(control_port(None, None), Ok(None));
}
/// The variable `soundcraft-cli app` reads may hold `host:port`: a loopback one gives its port.
#[test]
fn a_loopback_host_and_port_gives_its_port() {
for value in ["127.0.0.1:50494", "localhost:50494"] {
assert_eq!(parse_args(&["--control", value]).unwrap().control_port, Some(50494), "{value}");
}
}
/// The control server binds 127.0.0.1, so `[::1]` is another host.
#[test]
fn an_ipv6_loopback_host_is_not_loopback() {
assert!(usage_error(&["--control", "[::1]:50494"]).contains("invalid value '[::1]:50494' for '--control <PORT>'"));
}
#[test]
fn unknown_flags_and_repeats_are_usage_errors() {
assert!(usage_error(&["--bogus"]).contains("unexpected argument '--bogus'"));
assert!(usage_error(&["-x"]).contains("unexpected argument '-x'"));
assert!(usage_error(&["--demo", "--demo"]).contains("cannot be used multiple times"));
}
#[test]
fn double_dash_ends_options() {
let a = parse_args(&["a.wav", "--", "--demo", "-x"]).unwrap();
assert!(!a.demo);
assert_eq!(a.files, [PathBuf::from("a.wav"), PathBuf::from("--demo"), PathBuf::from("-x")]);
}
#[test]
fn a_finder_process_serial_number_is_dropped() {
assert_eq!(parse_args(&["-psn_0_12345", "a.wav"]).unwrap().files, [PathBuf::from("a.wav")]);
assert_eq!(parse_args(&["--", "-psn_x.wav"]).unwrap().files, [PathBuf::from("-psn_x.wav")]);
}
/// A file name that isn't valid Unicode reaches `files` as it is.
#[cfg(unix)]
#[test]
fn non_unicode_file_names_are_kept() {
use std::os::unix::ffi::OsStringExt as _;
let latin1 = OsString::from_vec(b"caf\xe9.wav".to_vec());
let a = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone()]).unwrap();
assert_eq!(a.files, [PathBuf::from(latin1)]);
}
}
+30 -396
View File
@@ -112,28 +112,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
let (y, t) = utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
/// clock can't overflow).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -405,13 +413,12 @@ impl log::Log for AppLogger {
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// Install the logger (filter from `rust_log`, the `RUST_LOG` value, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] or [`AppLogger::no_file`] once the
/// settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
pub fn install(rust_log: Option<&str>) -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let filter = Filter::parse(rust_log.unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
@@ -420,377 +427,4 @@ pub fn install() -> Option<&'static AppLogger> {
}
#[cfg(test)]
mod tests {
use super::*;
use log::Log;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("soundcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &str) {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
/// Run `f` on a thread the playback crate marks as the realtime audio thread.
fn on_audio_thread<T: Send>(f: impl FnOnce() -> T + Send) -> T {
std::thread::scope(|s| {
s.spawn(|| {
soundcraft_playback::mark_audio_thread();
f()
})
.join()
.expect("audio thread")
})
}
#[test]
fn the_default_filter_shows_soundcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("soundcraft"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft_playback"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft_clap_host::ffi"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("cpal"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,soundcraft_mix=trace,soundcraft_mix::pdc=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("soundcraft_mix::render"), LevelFilter::Trace);
assert_eq!(f.level_for("soundcraft_mix::pdc"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_ui_egui::mix_window"), LevelFilter::Debug);
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_engine"), LevelFilter::Warn);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive; the last bare level wins.
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "a".repeat(100_000);
let specs = [
"",
",,,",
"=",
"==",
"=debug",
"soundcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=debug",
"a=b=c",
"warn,,soundcraft=DEBUG",
" \t ",
"\u{0}",
"é*=trace",
&long,
];
for spec in specs {
let f = Filter::parse(spec);
for target in ["soundcraft", "", "🦀", "é", "a::b"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,soundcraft=DEBUG").level_for("soundcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("soundcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("soundcraft.2.log")), "run 2");
assert!(!dir.join("soundcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
assert!(rotate(&dir.join("below")).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line(
"2026-10-08T07:59:17.728Z",
log::Level::Warn,
"main",
"soundcraft_mix",
"eq_7band: the plugin did not accept its stored state"
),
"2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_max_pending_lines_drop_nothing() {
let dir = temp_dir("exact");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "soundcraft", "SoundCraft starting");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Debug, "soundcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("soundcraft: SoundCraft starting")), "{text}");
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("soundcraft_engine").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// A second launch rotates: the first log becomes `.1`.
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
assert_eq!(read(&dir.join("soundcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
std::thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..100 {
record(&logger, log::Level::Info, "soundcraft", "from a worker");
}
});
}
});
let text = read(&path);
assert_eq!(text.lines().count(), 400);
assert!(text.lines().all(|l| l.ends_with("soundcraft: from a worker")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
record(&logger, log::Level::Info, "soundcraft", "kept for a file");
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
assert!(e.contains(&dir.display().to_string()), "{e}");
record(&logger, log::Level::Error, "x", "still fine");
let sink = logger.sink.lock().expect("sink");
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
drop(sink);
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_run_without_a_log_file_keeps_nothing_for_one() {
let logger = AppLogger::new(Filter::parse("info"), false);
record(&logger, log::Level::Info, "soundcraft", "early");
logger.no_file();
record(&logger, log::Level::Info, "soundcraft", "late");
let sink = logger.sink.lock().expect("sink");
assert!(sink.file.is_none() && sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn a_poisoned_lock_does_not_stop_logging() {
let dir = temp_dir("poison");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = logger.sink.lock();
panic!("poison the sink");
}));
assert!(logger.sink.is_poisoned());
record(&logger, log::Level::Info, "soundcraft", "after the panic");
assert!(read(&path).contains("after the panic"));
let _ = std::fs::remove_dir_all(&dir);
}
// Realtime audio: the audio thread never takes the sink lock or touches the file.
#[test]
fn an_audio_thread_record_never_waits_for_the_sink() {
let dir = temp_dir("rt-nowait");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
// Another thread holds the sink (a slow disk, say) while the audio thread logs.
let held = logger.sink.lock().expect("sink");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::scope(|s| {
s.spawn(|| {
soundcraft_playback::mark_audio_thread();
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
let _ = tx.send(());
});
let done = rx.recv_timeout(Duration::from_secs(10)).is_ok();
drop(held);
assert!(done, "the audio thread blocked on the sink lock");
});
assert_eq!(read(&path), "", "nothing is written from the audio thread itself");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_first_audio_thread_record_is_written_later_with_a_count_of_the_rest() {
let dir = temp_dir("rt-report");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let path = logger.attach_dir(&dir).expect("attach");
on_audio_thread(|| {
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
for _ in 0..41 {
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: still unplugged");
}
// Filtered out: neither kept nor counted.
record(&logger, log::Level::Debug, "soundcraft_clap_host::plugin", "process failed");
});
assert_eq!(read(&path), "");
logger.report_audio_thread();
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines.first().is_some_and(|l| l.contains(" WARN [audio] soundcraft_playback: audio stream error: device unplugged")), "{text}");
assert!(lines.get(1).is_some_and(|l| l.contains("41 more log records from the realtime audio thread were not written")), "{text}");
// Reported once.
logger.report_audio_thread();
assert_eq!(read(&path), text);
// The next record from any other thread reports what the audio thread kept since, first.
on_audio_thread(|| record(&logger, log::Level::Error, "soundcraft_clap_host::ffi", "clap plugin: out of voices"));
record(&logger, log::Level::Info, "soundcraft", "UI thread");
let text = read(&path);
let tail: Vec<&str> = text.lines().skip(2).collect();
assert_eq!(tail.len(), 2, "{text}");
assert!(tail.first().is_some_and(|l| l.contains(" ERROR [audio] soundcraft_clap_host::ffi: clap plugin: out of voices")), "{text}");
assert!(tail.get(1).is_some_and(|l| l.ends_with("soundcraft: UI thread")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_long_audio_thread_record_is_cut_at_a_character_boundary() {
let dir = temp_dir("rt-long");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
let long = "é".repeat(REALTIME_MESSAGE_BYTES);
on_audio_thread(|| record(&logger, log::Level::Warn, "soundcraft", &long));
logger.report_audio_thread();
let text = read(&path);
let message = text.split("soundcraft: ").nth(1).expect("message");
assert!(message.ends_with("…\n"), "{text}");
assert!(message.len() <= REALTIME_MESSAGE_BYTES + "…\n".len(), "{}", message.len());
assert!(message.trim_end_matches(['…', '\n']).chars().all(|c| c == 'é'));
let _ = std::fs::remove_dir_all(&dir);
}
}
mod tests;
+380
View File
@@ -0,0 +1,380 @@
use super::*;
use log::Log;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("soundcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &str) {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
/// Run `f` on a thread the playback crate marks as the realtime audio thread.
fn on_audio_thread<T: Send>(f: impl FnOnce() -> T + Send) -> T {
std::thread::scope(|s| {
s.spawn(|| {
soundcraft_playback::mark_audio_thread();
f()
})
.join()
.expect("audio thread")
})
}
#[test]
fn the_default_filter_shows_soundcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("soundcraft"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft_playback"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft_clap_host::ffi"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("cpal"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,soundcraft_mix=trace,soundcraft_mix::pdc=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("soundcraft_mix::render"), LevelFilter::Trace);
assert_eq!(f.level_for("soundcraft_mix::pdc"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_ui_egui::mix_window"), LevelFilter::Debug);
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_engine"), LevelFilter::Warn);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive; the last bare level wins.
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "a".repeat(100_000);
let specs = [
"",
",,,",
"=",
"==",
"=debug",
"soundcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=debug",
"a=b=c",
"warn,,soundcraft=DEBUG",
" \t ",
"\u{0}",
"é*=trace",
&long,
];
for spec in specs {
let f = Filter::parse(spec);
for target in ["soundcraft", "", "🦀", "é", "a::b"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,soundcraft=DEBUG").level_for("soundcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("soundcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("soundcraft.2.log")), "run 2");
assert!(!dir.join("soundcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
assert!(rotate(&dir.join("below")).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "soundcraft_mix", "eq_7band: the plugin did not accept its stored state"),
"2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_max_pending_lines_drop_nothing() {
let dir = temp_dir("exact");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "soundcraft", "SoundCraft starting");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Debug, "soundcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("soundcraft: SoundCraft starting")), "{text}");
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("soundcraft_engine").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// A second launch rotates: the first log becomes `.1`.
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
assert_eq!(read(&dir.join("soundcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
std::thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..100 {
record(&logger, log::Level::Info, "soundcraft", "from a worker");
}
});
}
});
let text = read(&path);
assert_eq!(text.lines().count(), 400);
assert!(text.lines().all(|l| l.ends_with("soundcraft: from a worker")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
record(&logger, log::Level::Info, "soundcraft", "kept for a file");
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
assert!(e.contains(&dir.display().to_string()), "{e}");
record(&logger, log::Level::Error, "x", "still fine");
let sink = logger.sink.lock().expect("sink");
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
drop(sink);
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_run_without_a_log_file_keeps_nothing_for_one() {
let logger = AppLogger::new(Filter::parse("info"), false);
record(&logger, log::Level::Info, "soundcraft", "early");
logger.no_file();
record(&logger, log::Level::Info, "soundcraft", "late");
let sink = logger.sink.lock().expect("sink");
assert!(sink.file.is_none() && sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn a_poisoned_lock_does_not_stop_logging() {
let dir = temp_dir("poison");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = logger.sink.lock();
panic!("poison the sink");
}));
assert!(logger.sink.is_poisoned());
record(&logger, log::Level::Info, "soundcraft", "after the panic");
assert!(read(&path).contains("after the panic"));
let _ = std::fs::remove_dir_all(&dir);
}
// Realtime audio: the audio thread never takes the sink lock or touches the file.
#[test]
fn an_audio_thread_record_never_waits_for_the_sink() {
let dir = temp_dir("rt-nowait");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
// Another thread holds the sink (a slow disk, say) while the audio thread logs.
let held = logger.sink.lock().expect("sink");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::scope(|s| {
s.spawn(|| {
soundcraft_playback::mark_audio_thread();
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
let _ = tx.send(());
});
let done = rx.recv_timeout(Duration::from_secs(10)).is_ok();
drop(held);
assert!(done, "the audio thread blocked on the sink lock");
});
assert_eq!(read(&path), "", "nothing is written from the audio thread itself");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_first_audio_thread_record_is_written_later_with_a_count_of_the_rest() {
let dir = temp_dir("rt-report");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let path = logger.attach_dir(&dir).expect("attach");
on_audio_thread(|| {
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
for _ in 0..41 {
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: still unplugged");
}
// Filtered out: neither kept nor counted.
record(&logger, log::Level::Debug, "soundcraft_clap_host::plugin", "process failed");
});
assert_eq!(read(&path), "");
logger.report_audio_thread();
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines.first().is_some_and(|l| l.contains(" WARN [audio] soundcraft_playback: audio stream error: device unplugged")), "{text}");
assert!(lines.get(1).is_some_and(|l| l.contains("41 more log records from the realtime audio thread were not written")), "{text}");
// Reported once.
logger.report_audio_thread();
assert_eq!(read(&path), text);
// The next record from any other thread reports what the audio thread kept since, first.
on_audio_thread(|| record(&logger, log::Level::Error, "soundcraft_clap_host::ffi", "clap plugin: out of voices"));
record(&logger, log::Level::Info, "soundcraft", "UI thread");
let text = read(&path);
let tail: Vec<&str> = text.lines().skip(2).collect();
assert_eq!(tail.len(), 2, "{text}");
assert!(tail.first().is_some_and(|l| l.contains(" ERROR [audio] soundcraft_clap_host::ffi: clap plugin: out of voices")), "{text}");
assert!(tail.get(1).is_some_and(|l| l.ends_with("soundcraft: UI thread")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_long_audio_thread_record_is_cut_at_a_character_boundary() {
let dir = temp_dir("rt-long");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
let long = "é".repeat(REALTIME_MESSAGE_BYTES);
on_audio_thread(|| record(&logger, log::Level::Warn, "soundcraft", &long));
logger.report_audio_thread();
let text = read(&path);
let message = text.split("soundcraft: ").nth(1).expect("message");
assert!(message.ends_with("…\n"), "{text}");
assert!(message.len() <= REALTIME_MESSAGE_BYTES + "…\n".len(), "{}", message.len());
assert!(message.trim_end_matches(['…', '\n']).chars().all(|c| c == 'é'));
let _ = std::fs::remove_dir_all(&dir);
}
+135 -91
View File
@@ -1,6 +1,6 @@
//! SoundCraft desktop app.
//!
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`; `soundcraft --help` lists the flags and `SOUNDCRAFT_CONTROL_PORT`.
//!
//! `--control <port>` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` →
@@ -13,41 +13,71 @@
mod config;
mod control_server;
mod env;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod launch_args;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod switch;
use env::Env;
use soundcraft_audio_io::FileFormat;
use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp, UiState};
use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs};
use std::sync::Arc;
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
struct App(SoundApp, Option<&'static logging::AppLogger>, Env, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back.
fn set_title(ctx: &egui::Context, title: String) {
let id = egui::Id::unique("window_title");
if ctx.data(|d| d.get_temp::<String>(id)).as_deref() != Some(title.as_str()) {
ctx.data_mut(|d| d.insert_temp(id, title.clone()));
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
}
}
impl eframe::App for App {
/// Per-frame app logic: report audio-thread logs, run the shared app logic with the macOS menu driven around it,
/// then handle dropped paths, quit/close requests, window geometry, recent-list persistence and the window title.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// What the realtime audio thread logged is written here, off that thread.
if let Some(logger) = self.1 {
logger.report_audio_thread();
}
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.2 {
if let Some(menu) = &mut self.3 {
menu.process(&mut self.0);
}
self.0.logic(ctx);
self.0.guarded(|app| app.logic(ctx));
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.2 {
if let Some(menu) = &mut self.3 {
menu.sync(&self.0);
}
// Files dropped on the window: sessions open, audio/MIDI import.
let dropped: Vec<String> =
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect());
for p in dropped {
open_path(&mut self.0, &p);
// Files dropped on the window: audio on an instrument track or a Sampler window loads
// the sampler; otherwise sessions open and audio/MIDI import.
for f in self.0.take_dropped_files() {
if !f.path().as_os_str().is_empty() {
dialogs::open_path(&mut self.0, f.path());
}
}
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else if ctx.input(|i| i.viewport().close_requested()) && dialogs::ask_to_save(&mut self.0, "app.quit", &serde_json::json!({})) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
soundcraft_ui_egui::fit_window_to_screen(ctx);
let (inner, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().maximized.unwrap_or(false)));
self.0.ui.window_maximized = maximized;
if let Some(r) = inner.filter(|_| !maximized) {
self.0.ui.window_size = (r.size() * ctx.zoom_factor()).round().into();
}
self.0.ui.ui_scale = self.0.extra.ui_scale;
// Saved now rather than at exit: the recent list matters most after a crash.
if self.0.take_recent_changed() {
save_prefs(&self.2, &self.0.ui);
}
// With the system menu bar there is no egui bar showing the window and dirty state, so the
// title carries them; elsewhere the egui menu bar already does.
@@ -61,16 +91,19 @@ impl eframe::App for App {
} else {
format!("{} — SoundCraft", self.0.engine.session().name)
};
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
set_title(ctx, title);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
/// Forward raw input to the shared app hook so scripted gestures and host-key protection behave like the desktop
/// shell.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.0.guarded(|app| app.ui(ui));
}
fn on_exit(&mut self) {
save_prefs(&self.0.ui);
self.0.close_autosave();
save_prefs(&self.2, &self.0.ui);
if let Some(logger) = self.1 {
logger.report_audio_thread();
}
@@ -81,40 +114,16 @@ impl eframe::App for App {
}
}
fn open_path(app: &mut SoundApp, p: &str) {
let lower = p.to_ascii_lowercase();
let r = if lower.ends_with(".scraft") {
app.run("session.open", serde_json::json!({"path": p}))
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
app.run("file.import_midi", serde_json::json!({"path": p}))
} else {
let at = app.engine.session().edit.selection.start;
app.run("file.import_audio", serde_json::json!({"path": p, "at": at}))
};
if let Err(e) = r {
log::warn!("{p}: {e}");
}
}
/// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here.
fn config_dir() -> Option<std::path::PathBuf> {
config::config_dir()
}
fn prefs_path() -> Option<std::path::PathBuf> {
config_dir().map(|b| b.join("ui.json"))
fn prefs_path(env: &Env) -> Option<std::path::PathBuf> {
env.config_dir.as_ref().map(|b| b.join("ui.json"))
}
/// Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("SOUNDCRAFT_NO_PREFS").is_none()
}
fn load_prefs() -> Option<UiState> {
if !prefs_enabled() {
fn load_prefs(env: &Env) -> Option<UiState> {
if !env.prefs_enabled() {
return None;
}
let path = prefs_path()?;
let path = prefs_path(env)?;
match config::load_ui(&path) {
Ok(state) => state,
Err(error) => {
@@ -124,11 +133,11 @@ fn load_prefs() -> Option<UiState> {
}
}
fn save_prefs(ui: &UiState) {
if !prefs_enabled() {
fn save_prefs(env: &Env, ui: &UiState) {
if !env.prefs_enabled() {
return;
}
if let Some(path) = prefs_path()
if let Some(path) = prefs_path(env)
&& let Err(error) = config::save_ui(&path, ui)
{
log::warn!("{error}");
@@ -142,21 +151,24 @@ fn services() -> Services {
let d = match purpose {
"session.open" => d.add_filter("SoundCraft Session", &["scraft"]),
"file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]),
_ => d.add_filter(
"Audio",
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"],
),
_ => d.add_filter("Audio", &FileFormat::readable_extensions()),
};
d.pick_file().map(|p| p.to_string_lossy().into_owned())
d.pick_file()
})),
pick_save: Some(Box::new(|_purpose: &str, default: &str| {
let path = std::path::Path::new(default);
let mut d = rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]);
if let Some(n) = path.file_name().and_then(|n| n.to_str()) {
pick_save: Some(Box::new(|purpose: &str, path: &std::path::Path| {
let mut d = match purpose {
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &FileFormat::writable_extensions()),
_ => rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]),
};
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
d = d.set_directory(dir);
}
if let Some(n) = path.file_name().and_then(std::ffi::OsStr::to_str) {
d = d.set_file_name(n);
}
d.save_file().map(|p| p.to_string_lossy().into_owned())
d.save_file()
})),
pick_folder: Some(Box::new(|start: &std::path::Path| rfd::FileDialog::new().set_directory(start).pick_folder())),
}
}
@@ -171,36 +183,29 @@ fn app_icon() -> Option<egui::IconData> {
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so every start-up record (and the engine's panic hook, installed with the first
// Engine) is captured; see `logging`.
let logger = logging::install();
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("SoundCraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let demo = args.iter().any(|a| a == "--demo" || a == "--sample");
let no_audio = args.iter().any(|a| a == "--no-audio");
let control_port: Option<u16> = args
.iter()
.position(|a| a == "--control")
.and_then(|i| args.get(i + 1))
.and_then(|p| p.parse().ok())
.or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
let files: Vec<String> = args
.iter()
.enumerate()
.filter(|(i, a)| !(a.starts_with("--") || (i > &0 && args.get(i - 1).is_some_and(|p| p == "--control"))))
.map(|(_, a)| a.clone())
.collect();
log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
let logger = logging::install(env::rust_log().as_deref());
// Help, version and usage errors print and exit here, before a window or a log file exists.
let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit());
// After the arguments and the logger: reading resolves the settings folder, which creates it,
// and a malformed switch is logged.
let env = Env::read();
let (demo, no_audio, control_port) = (launch.demo, launch.no_audio, launch.control_port);
let files = launch.files;
log::info!("SoundCraft {}", soundcraft_automation::args::long_version());
// The log file lives in the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first. Runs without
// preferences (agents' test runs) log to standard error only, so they don't rotate away the
// user's own logs.
if let Some(logger) = logger {
match config_dir() {
_ if !prefs_enabled() => logger.no_file(),
match env.config_dir.as_deref() {
_ if !env.prefs_enabled() => logger.no_file(),
Some(dir) => match logger.attach_dir(&dir.join(logging::LOG_DIR)) {
Ok(path) => log::info!("log file {}", path.display()),
Err(e) => log::warn!("no log file: {e}"),
@@ -213,10 +218,12 @@ fn main() -> eframe::Result {
}
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let prefs = load_prefs(&env).unwrap_or_default();
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("SoundCraft")
.with_inner_size([1600.0, 1000.0])
.with_inner_size(sane_window_size(prefs.window_size))
.with_maximized(prefs.window_maximized)
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_app_id("ai.storyteller.soundcraft"),
@@ -225,26 +232,39 @@ fn main() -> eframe::Result {
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
}
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; SOUNDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
if env.use_xwayland(std::env::var_os("DISPLAY").as_ref()) {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
eframe::run_native(
"SoundCraft",
options,
Box::new(move |cc| {
let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) };
let player =
if no_audio { None } else { Some(soundcraft_playback::Player::with_config(Arc::new(engine.session().clone()), &prefs.audio)) };
let mut app = SoundApp::new(engine, player, services());
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
if let Some(ui) = load_prefs() {
app.ui = ui;
}
app.set_startup_update_check(true);
app.set_system_fonts(env.system_fonts());
app.autosave_dir = env.config_dir.as_ref().map(|d| d.join("Autosave"));
app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default();
app.preset_dir = env.config_dir.as_ref().map(|d| d.join("Presets"));
app.extra.ui_scale = prefs.ui_scale;
app.ui = prefs;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
for f in &files {
open_path(&mut app, f);
dialogs::open_path(&mut app, f);
}
// If the system menu bar cannot be built, keep the egui menu bar rather than fail to start.
#[cfg(target_os = "macos")]
@@ -261,9 +281,33 @@ fn main() -> eframe::Result {
Ok(Box::new(App(
app,
logger,
env,
#[cfg(target_os = "macos")]
menu,
)))
}),
)
}
/// A saved window size, or the default when the prefs file holds nonsense (NaN, negative, tiny).
fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] {
if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] }
}
#[cfg(test)]
mod tests {
use super::set_title;
#[test]
fn the_title_is_sent_only_when_it_changes() {
let ctx = egui::Context::default();
let titles = |title: &str| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| set_title(ui.ctx(), title.to_string()));
out.textures_delta.clear();
out.viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::Title(_))).count()
};
assert_eq!(titles("Song — SoundCraft"), 1);
assert_eq!(titles("Song — SoundCraft"), 0);
assert_eq!(titles("Song * — SoundCraft"), 1);
}
}
+17 -4
View File
@@ -1,5 +1,7 @@
//! macOS system menu bar. All actions use the same menu dispatch as the egui host.
use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use soundcraft_ui_egui::{SoundApp, menus};
use std::sync::mpsc::{Receiver, channel};
@@ -36,8 +38,9 @@ impl NativeMenu {
app_menu.append(&PredefinedMenuItem::separator())?;
app_menu.append(&PredefinedMenuItem::services(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
app_menu.append(&PredefinedMenuItem::hide(Some("Hide SoundCraft")))?;
app_menu.append(&PredefinedMenuItem::hide_others(None))?;
// muda's own Hide items take ⌘H and ⌥⌘H, which are Heal Separation and Copy to Send.
app_menu.append(&MenuItem::with_id("hide", "Hide SoundCraft", true, None))?;
app_menu.append(&MenuItem::with_id("hide_others", "Hide Others", true, None))?;
app_menu.append(&PredefinedMenuItem::show_all(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?;
@@ -82,7 +85,12 @@ impl NativeMenu {
while let Ok(event) = self.events.try_recv() {
// A click changes state (and Cocoa flips the item's check mark): sync on the next pass.
self.last_sync = None;
if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
if (event.id == "hide" || event.id == "hide_others")
&& let Some(mtm) = MainThreadMarker::new()
{
let ns = NSApplication::sharedApplication(mtm);
if event.id == "hide" { ns.hide(None) } else { ns.hideOtherApplications(None) }
} else if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
&& let Some(command) = &entry.command
&& menus::item_state(app, &entry.path, Some(command)).0
{
@@ -105,6 +113,11 @@ impl NativeMenu {
self.last_sync = Some(now);
for entry in &mut self.entries {
let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref());
if let Some(label) = menus::live_label(app, entry.command.as_deref())
&& entry.item.text() != label
{
entry.item.set_text(label);
}
if enabled != entry.enabled {
entry.item.set_enabled(enabled);
entry.enabled = enabled;
+46
View File
@@ -0,0 +1,46 @@
//! Boolean environment switches (`SOUNDCRAFT_WAYLAND`, `SOUNDCRAFT_NO_PREFS`, ...): one parser, so every
//! variable accepts the same spellings.
/// `1/on/true/yes` or `0/off/false/no`, case- and space-insensitive. Blank or unset is `Default`;
/// anything else is `Default` and logged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum Switch {
On,
Off,
#[default]
Default,
}
impl Switch {
/// Parse the value of the variable `name`; `name` only appears in the warning.
pub(crate) fn parse(name: &str, value: Option<&str>) -> Switch {
let Some(value) = value else { return Switch::Default };
match value.trim().to_ascii_lowercase().as_str() {
"1" | "on" | "true" | "yes" => Switch::On,
"0" | "off" | "false" | "no" => Switch::Off,
"" => Switch::Default,
_ => {
log::warn!("{name}={value:?} is not 1/on/true/yes or 0/off/false/no; using the default");
Switch::Default
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_every_spelling() {
for v in ["1", "on", "true", "yes", "ON", "True", " Yes ", "\t1\n"] {
assert_eq!(Switch::parse("X", Some(v)), Switch::On, "{v:?}");
}
for v in ["0", "off", "false", "no", "OFF", "False", " No ", " 0 "] {
assert_eq!(Switch::parse("X", Some(v)), Switch::Off, "{v:?}");
}
for v in [None, Some(""), Some(" "), Some("maybe"), Some("2"), Some("onn")] {
assert_eq!(Switch::parse("X", v), Switch::Default, "{v:?}");
}
}
}
+55
View File
@@ -0,0 +1,55 @@
//! The desktop binary's command line, run as a process: the variable fallback of `--control`.
use std::process::{Command, Output};
fn soundcraft(args: &[&str], control_port: &str) -> Output {
Command::new(env!("CARGO_BIN_EXE_soundcraft"))
.args(args)
.env("SOUNDCRAFT_NO_PREFS", "1")
.env("SOUNDCRAFT_CONTROL_PORT", control_port)
.env_remove("RUST_LOG")
// Without a display the window cannot open, so a launch that passes parsing ends instead of
// staying up.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.output()
.expect("run soundcraft")
}
#[test]
fn a_bad_variable_is_a_usage_error_naming_it() {
let out = soundcraft(&["--no-audio"], "nope");
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{stderr}");
assert!(stderr.contains("invalid value 'nope' for 'SOUNDCRAFT_CONTROL_PORT'"), "{stderr}");
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn a_blank_variable_is_unset() {
let out = soundcraft(&["--no-audio"], " ");
let stderr = String::from_utf8_lossy(&out.stderr);
assert_ne!(out.status.code(), Some(2), "{stderr}");
assert!(!stderr.contains("SOUNDCRAFT_CONTROL_PORT"), "{stderr}");
}
/// `soundcraft-cli app` takes `host:port` from the same variable.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn a_loopback_host_and_port_variable_is_accepted() {
let out = soundcraft(&["--no-audio"], "127.0.0.1:0");
let stderr = String::from_utf8_lossy(&out.stderr);
assert_ne!(out.status.code(), Some(2), "{stderr}");
assert!(!stderr.contains("SOUNDCRAFT_CONTROL_PORT"), "{stderr}");
}
/// The variable may point `soundcraft-cli app` at another machine: the app opens, with a warning
/// and no control server.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn another_host_in_the_variable_is_ignored() {
let out = soundcraft(&["--no-audio"], "10.0.0.5:7979");
let stderr = String::from_utf8_lossy(&out.stderr);
assert_ne!(out.status.code(), Some(2), "{stderr}");
assert!(stderr.contains("SOUNDCRAFT_CONTROL_PORT=10.0.0.5:7979 names another host, so no control server starts"), "{stderr}");
}
+5
View File
@@ -2,3 +2,8 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+26
View File
@@ -35,3 +35,29 @@ sync_github_name = false
[people.salignatmoandal]
real_name = "Mawen Salignat-Moandal"
sync_github_name = true
[people.vbasky]
real_name = "Vikram Bhaskaran"
display_name = "Vikram Bhaskaran"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.Momentum96]
sync_github_name = true
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.mattburlage]
display_name = "Matt Smith-Burlage"
[people.ProdByBuddha]
sync_github_name = true
+3 -1
View File
@@ -20,7 +20,9 @@ log.workspace = true
unsafe_code = "deny"
[lints.clippy]
cognitive_complexity = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
too_many_arguments = "deny"
type_complexity = "allow"
+270 -33
View File
@@ -2,11 +2,14 @@
//! thin safe wrapper over one Audio Unit instance.
//!
//! Every pointer handed to the Audio Unit points into memory this module owns for at least as
//! long as the unit can use it: the input state the render callback reads is a leaked `Box`
//! freed only after the unit is disposed, and output buffers are re-pointed before each render.
//! long as the unit can use it: the input state the render callback reads and the host time the
//! host callbacks read are leaked `Box`es freed only after the unit is disposed, and output buffers
//! are re-pointed before each render.
use soundcraft_dsp::Transport;
use std::ffi::{CStr, c_char, c_void};
use std::ptr;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
type OSStatus = i32;
type AudioComponent = *mut c_void;
@@ -40,6 +43,7 @@ const PROP_MAX_FRAMES: u32 = 14;
const PROP_PARAMETER_VALUE_STRINGS: u32 = 16;
const PROP_TAIL_TIME: u32 = 20;
const PROP_SET_RENDER_CALLBACK: u32 = 23;
const PROP_HOST_CALLBACKS: u32 = 27;
const SCOPE_GLOBAL: u32 = 0;
const SCOPE_INPUT: u32 = 1;
@@ -140,6 +144,153 @@ struct AudioBufferListHeader {
first: AudioBuffer,
}
type BeatAndTempoProc = unsafe extern "C" fn(*mut c_void, *mut f64, *mut f64) -> OSStatus;
type MusicalTimeProc = unsafe extern "C" fn(*mut c_void, *mut u32, *mut f32, *mut u32, *mut f64) -> OSStatus;
type TransportStateProc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus;
type TransportState2Proc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus;
#[repr(C)]
struct HostCallbackInfo {
host_user_data: *mut c_void,
beat_and_tempo: Option<BeatAndTempoProc>,
musical_time_location: Option<MusicalTimeProc>,
transport_state: Option<TransportStateProc>,
transport_state2: Option<TransportState2Proc>,
}
/// What the host callbacks report: the transport of the block being rendered. Atomics, because a
/// unit may also ask from another thread.
struct HostTime {
playing: AtomicBool,
recording: AtomicBool,
changed: AtomicBool,
samples: AtomicI64,
tempo: AtomicU64,
quarters: AtomicU64,
bar_start: AtomicU64,
numerator: AtomicU32,
denominator: AtomicU32,
sample_rate: AtomicU64,
}
impl HostTime {
fn new() -> HostTime {
HostTime {
playing: AtomicBool::new(false),
recording: AtomicBool::new(false),
changed: AtomicBool::new(false),
samples: AtomicI64::new(0),
tempo: AtomicU64::new(120f64.to_bits()),
quarters: AtomicU64::new(0),
bar_start: AtomicU64::new(0),
numerator: AtomicU32::new(4),
denominator: AtomicU32::new(4),
sample_rate: AtomicU64::new(48_000f64.to_bits()),
}
}
fn set(&self, t: &Transport) {
if self.playing.swap(t.playing, Ordering::Relaxed) != t.playing {
self.changed.store(true, Ordering::Relaxed);
}
self.recording.store(t.recording, Ordering::Relaxed);
self.samples.store(t.samples, Ordering::Relaxed);
self.tempo.store(t.tempo.to_bits(), Ordering::Relaxed);
self.quarters.store(t.quarters.to_bits(), Ordering::Relaxed);
self.bar_start.store(t.bar_start.to_bits(), Ordering::Relaxed);
self.numerator.store(t.numerator, Ordering::Relaxed);
self.denominator.store(t.denominator, Ordering::Relaxed);
}
}
fn load_f64(a: &AtomicU64) -> f64 {
f64::from_bits(a.load(Ordering::Relaxed))
}
/// Writes through an out-pointer the unit may leave null.
///
/// # Safety
/// `p` is null or valid for writing a `T`.
unsafe fn put<T>(p: *mut T, v: T) {
// SAFETY: per the contract above.
if let Some(p) = unsafe { p.as_mut() } {
*p = v;
}
}
unsafe extern "C" fn beat_and_tempo(user: *mut c_void, beat: *mut f64, tempo: *mut f64) -> OSStatus {
// SAFETY: `user` is the `HostTime` leaked by `Instance::new`, alive until after the unit is
// disposed.
let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 };
// SAFETY: the unit's out-pointers (or null).
unsafe {
put(beat, load_f64(&t.quarters));
put(tempo, load_f64(&t.tempo));
}
0
}
unsafe extern "C" fn musical_time(
user: *mut c_void,
to_next_beat: *mut u32,
numerator: *mut f32,
denominator: *mut u32,
downbeat: *mut f64,
) -> OSStatus {
// SAFETY: as in `beat_and_tempo`.
let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 };
let q = load_f64(&t.quarters);
let per_quarter = load_f64(&t.sample_rate) * 60.0 / load_f64(&t.tempo).max(1.0);
let next = ((q.ceil() - q) * per_quarter).round();
let next = if next.is_finite() { next.clamp(0.0, f64::from(u32::MAX)) as u32 } else { 0 };
// SAFETY: the unit's out-pointers (or null).
unsafe {
put(to_next_beat, next);
put(numerator, t.numerator.load(Ordering::Relaxed) as f32);
put(denominator, t.denominator.load(Ordering::Relaxed));
put(downbeat, load_f64(&t.bar_start));
}
0
}
unsafe extern "C" fn transport_state2(
user: *mut c_void,
playing: *mut u8,
recording: *mut u8,
changed: *mut u8,
sample: *mut f64,
cycling: *mut u8,
cycle_start: *mut f64,
cycle_end: *mut f64,
) -> OSStatus {
// SAFETY: as in `beat_and_tempo`.
let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 };
// SAFETY: the unit's out-pointers (or null).
unsafe {
put(playing, u8::from(t.playing.load(Ordering::Relaxed)));
put(recording, u8::from(t.recording.load(Ordering::Relaxed)));
put(changed, u8::from(t.changed.swap(false, Ordering::Relaxed)));
put(sample, t.samples.load(Ordering::Relaxed) as f64);
put(cycling, 0);
put(cycle_start, 0.0);
put(cycle_end, 0.0);
}
0
}
unsafe extern "C" fn transport_state(
user: *mut c_void,
playing: *mut u8,
changed: *mut u8,
sample: *mut f64,
cycling: *mut u8,
cycle_start: *mut f64,
cycle_end: *mut f64,
) -> OSStatus {
// SAFETY: the same pointers as `transport_state2`, without the recording flag.
unsafe { transport_state2(user, playing, ptr::null_mut(), changed, sample, cycling, cycle_start, cycle_end) }
}
type AuRenderCallback = unsafe extern "C" fn(
ref_con: *mut c_void,
action_flags: *mut u32,
@@ -361,6 +512,8 @@ pub struct Instance {
unit: AudioUnit,
/// Leaked `Box<InputState>` read by `input_callback`; freed in `Drop` after disposal.
input: *mut InputState,
/// Leaked `Box<HostTime>` read by the host callbacks; freed in `Drop` after disposal.
time: *mut HostTime,
outputs: Vec<Vec<f32>>,
abl: AudioBufferList,
has_input: bool,
@@ -369,11 +522,13 @@ pub struct Instance {
max_frames: usize,
sample_rate: f64,
sample_time: f64,
link: std::sync::Arc<StateLink>,
}
// SAFETY: an Audio Unit instance may be used from any thread as long as calls are not
// concurrent; `Instance` is only reachable through `&mut`/`&` of its owner, which the `Plugin`
// contract keeps on one thread at a time. The input state is owned exclusively by it.
// contract keeps on one thread at a time. The input state and host time are owned exclusively by
// it (the host time is all atomics).
unsafe impl Send for Instance {}
impl Instance {
@@ -398,19 +553,42 @@ impl Instance {
return Err(format!("AudioComponentInstanceNew failed ({st})"));
}
let input = Box::into_raw(Box::new(InputState { bufs: Vec::new(), frames: 0 }));
let time = Box::into_raw(Box::new(HostTime::new()));
let empty = AudioBuffer { number_channels: 1, data_byte_size: 0, data: ptr::null_mut() };
Ok(Instance {
let inst = Instance {
unit,
input,
time,
outputs: Vec::new(),
abl: AudioBufferList { number_buffers: 0, buffers: [empty; MAX_AU_CHANNELS] },
has_input,
link: std::sync::Arc::new(StateLink(std::sync::Mutex::new(LinkedUnit(Some(unit))))),
initialized: false,
channels: 0,
max_frames: 0,
sample_rate: 48_000.0,
sample_time: 0.0,
})
};
let callbacks = HostCallbackInfo {
host_user_data: time.cast(),
beat_and_tempo: Some(beat_and_tempo),
musical_time_location: Some(musical_time),
transport_state: Some(transport_state),
transport_state2: Some(transport_state2),
};
// Units that never ask for host time may refuse it.
let _ = inst.set_property(PROP_HOST_CALLBACKS, SCOPE_GLOBAL, 0, &callbacks);
Ok(inst)
}
fn time(&self) -> &HostTime {
// SAFETY: `time` is the `HostTime` leaked in `new`, freed only in `Drop`.
unsafe { &*self.time }
}
/// The transport the host callbacks report during the next renders.
pub fn set_transport(&self, t: &Transport) {
self.time().set(t);
}
fn set_property<T>(&self, id: u32, scope: u32, element: u32, value: &T) -> OSStatus {
@@ -490,6 +668,7 @@ impl Instance {
self.initialized = true;
self.channels = ch;
self.sample_rate = sample_rate;
self.time().sample_rate.store(sample_rate.to_bits(), Ordering::Relaxed);
self.outputs = (0..ch).map(|_| vec![0.0; mf]).collect();
// SAFETY: the callback only runs inside `AudioUnitRender`, which is not running now
// (we hold `&mut self`), so this exclusive access does not alias.
@@ -678,35 +857,12 @@ impl Instance {
out
}
/// The unit's `ClassInfo` as a binary property list.
pub fn save_state(&self) -> Option<Vec<u8>> {
let mut plist: CFTypeRef = ptr::null();
let mut size = std::mem::size_of::<CFTypeRef>() as u32;
// SAFETY: the property is a `CFPropertyListRef` returned +1; `plist` is a local pointer.
let st = unsafe { AudioUnitGetProperty(self.unit, PROP_CLASS_INFO, SCOPE_GLOBAL, 0, (&mut plist as *mut CFTypeRef).cast(), &mut size) };
if st != 0 || plist.is_null() {
release(plist);
return None;
}
let mut err: CFTypeRef = ptr::null();
// SAFETY: `plist` is a live property list; `err` is a local out pointer.
let data = unsafe { CFPropertyListCreateData(ptr::null(), plist, CF_PROPERTY_LIST_BINARY_FORMAT, 0, &mut err) };
release(err);
release(plist);
if data.is_null() {
return None;
}
// SAFETY: `data` is a live CFData we own; its byte pointer is valid for its length.
let out = unsafe {
let len = CFDataGetLength(data);
let p = CFDataGetBytePtr(data);
if len <= 0 || len as usize > MAX_STATE || p.is_null() { None } else { Some(std::slice::from_raw_parts(p, len as usize).to_vec()) }
};
release(data);
out
/// The link for state handles.
pub fn state_link(&self) -> std::sync::Arc<StateLink> {
std::sync::Arc::clone(&self.link)
}
/// Restores a blob from [`Instance::save_state`] (hostile input: parsed by CoreFoundation,
/// Restores a blob from [`StateLink::save_state`] (hostile input: parsed by CoreFoundation,
/// must be a dictionary, and the unit validates it).
pub fn load_state(&self, bytes: &[u8]) -> bool {
if bytes.is_empty() || bytes.len() > MAX_STATE {
@@ -733,16 +889,71 @@ impl Instance {
}
}
struct LinkedUnit(Option<AudioUnit>);
// SAFETY: the pointer is only used under the link's mutex, while the instance keeps the unit alive.
unsafe impl Send for LinkedUnit {}
/// The link between an instance and its state handles: the unit's state is read on the main
/// thread while the instance renders on the audio thread (hosts make property calls from other
/// threads during rendering). The instance clears the link under its lock before it disposes of
/// the unit, so a handle never reads a dead unit.
pub(crate) struct StateLink(std::sync::Mutex<LinkedUnit>);
impl StateLink {
fn lock(&self) -> std::sync::MutexGuard<'_, LinkedUnit> {
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The unit's `ClassInfo` as a binary property list (`None` once the instance is gone).
pub fn save_state(&self) -> Option<Vec<u8>> {
let g = self.lock();
let unit = g.0?;
let mut plist: CFTypeRef = ptr::null();
let mut size = std::mem::size_of::<CFTypeRef>() as u32;
// SAFETY: the unit is live while the link holds it (see the type); the property is a
// `CFPropertyListRef` returned +1; `plist` is a local pointer.
let st = unsafe { AudioUnitGetProperty(unit, PROP_CLASS_INFO, SCOPE_GLOBAL, 0, (&mut plist as *mut CFTypeRef).cast(), &mut size) };
if st != 0 || plist.is_null() {
release(plist);
return None;
}
let mut err: CFTypeRef = ptr::null();
// SAFETY: `plist` is a live property list; `err` is a local out pointer.
let data = unsafe { CFPropertyListCreateData(ptr::null(), plist, CF_PROPERTY_LIST_BINARY_FORMAT, 0, &mut err) };
release(err);
release(plist);
if data.is_null() {
return None;
}
// SAFETY: `data` is a live CFData we own; its byte pointer is valid for its length.
let out = unsafe {
let len = CFDataGetLength(data);
let p = CFDataGetBytePtr(data);
if len <= 0 || len as usize > MAX_STATE || p.is_null() { None } else { Some(std::slice::from_raw_parts(p, len as usize).to_vec()) }
};
release(data);
out
}
fn kill(&self) {
self.lock().0 = None;
}
}
impl Drop for Instance {
fn drop(&mut self) {
self.link.kill();
// SAFETY: the unit is live (disposed only here); after disposal nothing can call the
// input callback, so the leaked input state can be freed.
// input callback or the host callbacks, so the leaked input state and host time can be
// freed.
unsafe {
if self.initialized {
AudioUnitUninitialize(self.unit);
}
AudioComponentInstanceDispose(self.unit);
drop(Box::from_raw(self.input));
drop(Box::from_raw(self.time));
}
}
}
@@ -751,6 +962,31 @@ impl Drop for Instance {
mod tests {
use super::*;
#[test]
fn host_callbacks_report_the_transport() {
let time = HostTime::new();
time.set(&Transport { playing: true, tempo: 120.0, numerator: 3, denominator: 4, samples: 48_000, quarters: 2.25, ..Transport::default() });
let user = ptr::from_ref(&time).cast_mut().cast::<c_void>();
let (mut beat, mut tempo) = (0.0, 0.0);
// SAFETY: `user` points at a live `HostTime`; the out-pointers are locals.
assert_eq!(unsafe { beat_and_tempo(user, &mut beat, &mut tempo) }, 0);
assert_eq!((beat, tempo), (2.25, 120.0));
let (mut next, mut num, mut den, mut down) = (0u32, 0f32, 0u32, -1.0);
// SAFETY: as above.
assert_eq!(unsafe { musical_time(user, &mut next, &mut num, &mut den, &mut down) }, 0);
assert_eq!((next, num, den, down), (18_000, 3.0, 4, 0.0));
let (mut playing, mut recording, mut changed, mut sample) = (0u8, 1u8, 0u8, 0.0);
let (no_flag, no_beat) = (ptr::null_mut::<u8>(), ptr::null_mut::<f64>());
// SAFETY: as above; the unit may pass null for what it doesn't want.
let st = unsafe { transport_state2(user, &mut playing, &mut recording, &mut changed, &mut sample, no_flag, no_beat, no_beat) };
assert_eq!((st, playing, recording, changed, sample), (0, 1, 0, 1, 48_000.0));
// SAFETY: as above.
unsafe { transport_state(user, &mut playing, &mut changed, no_beat, no_flag, no_beat, no_beat) };
assert_eq!(changed, 0, "a change is reported once");
// SAFETY: a null user pointer is refused before anything is read.
assert_eq!(unsafe { beat_and_tempo(ptr::null_mut(), &mut beat, &mut tempo) }, -50);
}
#[test]
fn struct_layouts_match_the_c_headers() {
assert_eq!(std::mem::size_of::<AudioComponentDescription>(), 20);
@@ -761,6 +997,7 @@ mod tests {
assert_eq!(std::mem::offset_of!(AudioBufferList, buffers), 8);
assert_eq!(std::mem::offset_of!(AudioBufferListHeader, first), 8);
assert_eq!(std::mem::size_of::<AuRenderCallbackStruct>(), 16);
assert_eq!(std::mem::size_of::<HostCallbackInfo>(), 40);
assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, unit_name), 56);
assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, cf_name_string), 72);
assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, flags), 96);
+6 -4
View File
@@ -23,8 +23,10 @@
//! (`kAudioUnitProperty_ClassInfo`) as a binary plist; [`Plugin::load_state`] parses it back and
//! sets it, then re-reads the parameter values.
//!
//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses,
//! parameters outside the global scope, and host callbacks (tempo, transport).
//! Host callbacks report the session's tempo, meter, position and play state.
//!
//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses, and
//! parameters outside the global scope.
//!
//! Off macOS (and on `wasm32`) the crate compiles to stubs: scans are empty and nothing can be
//! created.
@@ -190,7 +192,7 @@ pub enum AuError {
#[cfg(target_os = "macos")]
mod api {
use super::*;
use super::{AuDescriptor, AuError, Plugin, PluginInfo, registry};
pub fn scan() -> Vec<AuDescriptor> {
registry::scan()
@@ -208,7 +210,7 @@ mod api {
#[cfg(not(target_os = "macos"))]
mod api {
use super::*;
use super::{AuDescriptor, AuError, Plugin, PluginInfo};
pub fn scan() -> Vec<AuDescriptor> {
Vec::new()
+19 -8
View File
@@ -1,7 +1,7 @@
//! `AuPlugin`: a loaded Audio Unit behind the safe `soundcraft_dsp::Plugin` trait.
use crate::ffi::Instance;
use soundcraft_dsp::{Plugin, PluginInfo};
use soundcraft_dsp::{Plugin, PluginInfo, Transport};
/// Most MIDI events queued between two blocks (extra events are dropped).
const EVENT_CAP: usize = 1024;
@@ -170,13 +170,12 @@ impl Plugin for AuPlugin {
if ch == 1 && au_ch >= 2 {
if let (Some(l), Some(r)) = (self.inst.output(0, n), self.inst.output(1, n)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
let v = 0.5 * (a + b);
*d = if v.is_finite() { v } else { 0.0 };
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = self.inst.output(c.min(au_ch.saturating_sub(1)), n) {
for (d, &s) in dst.iter_mut().zip(src) {
*d = if s.is_finite() { s } else { 0.0 };
*d = s;
}
}
}
@@ -200,6 +199,10 @@ impl Plugin for AuPlugin {
self.latency
}
fn set_transport(&mut self, transport: &Transport) {
self.inst.set_transport(transport);
}
fn tail_samples(&self) -> usize {
self.tail
}
@@ -227,6 +230,13 @@ impl Plugin for AuPlugin {
}
}
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
let [status, data1, data2] = msg;
if status & 0xF0 >= 0xA0 && status & 0xF0 < 0xF0 {
self.push_event(MidiEvent { time: u32::try_from(offset).unwrap_or(u32::MAX), status, data1: data1 & 0x7F, data2: data2 & 0x7F });
}
}
fn all_notes_off(&mut self) {
for k in 0..128u8 {
if self.held.get(k as usize).copied().unwrap_or(false) {
@@ -237,10 +247,6 @@ impl Plugin for AuPlugin {
self.push_event(MidiEvent { time: 0, status: 0xB0, data1: 123, data2: 0 });
}
fn save_state(&mut self) -> Option<Vec<u8>> {
self.inst.save_state()
}
fn load_state(&mut self, data: &[u8]) -> bool {
let ok = self.inst.load_state(data);
if ok {
@@ -248,4 +254,9 @@ impl Plugin for AuPlugin {
}
ok
}
fn state_reader(&mut self) -> Option<Box<dyn soundcraft_dsp::PluginStateReader>> {
let link = self.inst.state_link();
Some(Box::new(move || link.save_state()))
}
}
+4
View File
@@ -0,0 +1,4 @@
//! Integration tests, built as one test binary: each file is a module.
#[cfg(target_os = "macos")]
mod system_units;
@@ -1,6 +1,5 @@
//! End to end against the Audio Units Apple ships with macOS: scan → instantiate → params →
//! process → state, plus an instrument playing a note. Skips (passes) when a unit is missing.
#![cfg(target_os = "macos")]
use soundcraft_dsp::{Category, Plugin};
@@ -100,6 +99,35 @@ fn lowpass_attenuates_highs() {
assert!(run_tone(p.as_mut(), 100.0).is_finite());
}
#[test]
fn state_reader_reads_while_another_thread_renders() {
if !available(LOWPASS) {
return;
}
let info = soundcraft_au_host::plugin_info(LOWPASS).unwrap();
let cutoff = info.params.iter().find(|q| q.unit == soundcraft_dsp::Unit::Hz).unwrap();
let mut p = soundcraft_au_host::create(LOWPASS).unwrap();
p.prepare(48_000.0, 512, 2);
assert!(p.set_param(cutoff.id, 500.0));
let mut reader = p.state_reader().expect("every unit has a ClassInfo");
let audio = std::thread::spawn(move || {
for _ in 0..4 {
run_tone(p.as_mut(), 100.0);
}
p
});
while !audio.is_finished() {
assert!(reader.save_state().is_some());
}
let p = audio.join().unwrap();
let state = reader.save_state().expect("ClassInfo");
drop(p);
let mut q = soundcraft_au_host::create(LOWPASS).unwrap();
assert_eq!(reader.save_state(), None);
assert!(q.load_state(&state));
assert!((q.param(cutoff.id).unwrap() - 500.0).abs() < 1.0, "{:?}", q.param(cutoff.id));
}
#[test]
fn delay_runs_mono_stereo_and_odd_blocks() {
if !available(DELAY) {
@@ -167,4 +195,23 @@ fn dls_synth_plays_a_note() {
assert!(e > 1e-3, "{e}");
p.note_off(0, 64);
p.reset();
// Controllers reach the synth: MIDI volume 0 silences a note. (In this test because creating
// two DLS instances at once on two threads crashes inside the unit.)
p.prepare(48_000.0, 512, 2);
let loudest = |p: &mut Box<dyn Plugin>, volume: u8| {
p.midi(0, [0xB0, 7, volume]);
p.note_on(0, 60, 110);
let mut energy = 0.0f32;
for _ in 0..20 {
let mut io = vec![vec![0.0f32; 512]; 2];
p.process(&mut io, 512);
energy = energy.max(rms(&io[0]));
}
p.note_off(0, 60);
p.reset();
energy
};
let full = loudest(&mut p, 127);
let muted = loudest(&mut p, 0);
assert!(full > 1e-3 && muted < full * 0.01, "volume 127: {full}, volume 0: {muted}");
}
+85 -34
View File
@@ -1,8 +1,9 @@
//! Native AIFF / AIFF-C reader and writer (Apple "Audio Interchange File Format" 1.3 and the
//! AIFF-C draft: big-endian IFF chunks, 80-bit IEEE extended sample rate).
use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, clamp_float, deinterleave, tag, validate_buffer};
use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, deinterleave, sample_format, tag, validate_buffer, validate_layout, write_interleaved};
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
use std::io::{Cursor, Seek, SeekFrom, Write};
/// AIFF-C version 1 timestamp for the FVER chunk.
const AIFC_VERSION_1: u32 = 0xA280_5140;
@@ -162,25 +163,94 @@ fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
/// Encode AIFF (integer depths) or AIFF-C `fl32` (Float32).
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
validate_buffer(buf)?;
let channels = buf.channels.len();
let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?;
let frames = buf.frames();
let head_len = header(buf.channels.len(), buf.sample_rate, depth, frames as u64)?.len();
let data_len = usize::from(sample_format(depth).0 / 8).saturating_mul(buf.channels.len()).saturating_mul(frames);
let total = head_len.saturating_add(data_len).saturating_add(1);
let mut out = Vec::new();
out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?;
let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?;
w.write(&buf.channels, frames)?;
Ok(w.finish()?.into_inner())
}
/// An AIFF written block by block: a placeholder header first, then the real one (the same
/// length) written over it by [`Writer::finish`].
pub(crate) struct Writer<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
depth: BitDepth,
quantizers: Vec<Quantizer>,
frames: u64,
bytes: Vec<u8>,
/// Set when a block write has failed: the sink may hold a prefix of that block while
/// `frames` does not count it, so the writer refuses every later call.
failed: bool,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
validate_layout(channels, sample_rate)?;
let start = out.stream_position()?;
out.write_all(&header(channels, sample_rate, depth, 0)?)?;
let quantizers = Quantizer::per_channel(u32::from(sample_format(depth).0), dither, channels);
Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new(), failed: false })
}
/// The frame count after `frames` more, or an error when AIFF can't hold that many (4 GiB).
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
let total = self.frames.saturating_add(frames);
header(self.channels, self.sample_rate, self.depth, total)?;
Ok(total)
}
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
/// fit (see [`Writer::check_room`]).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
if self.failed {
return Err(crate::failed_write_error());
}
let total = self.check_room(frames as u64)?;
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true) {
self.failed = true;
return Err(AudioError::from(e));
}
self.frames = total;
Ok(())
}
pub(crate) fn finish(mut self) -> Result<W> {
if self.failed {
return Err(crate::failed_write_error());
}
let data_len = u64::from(sample_format(self.depth).0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
if data_len % 2 == 1 {
self.out.write_all(&[0])?;
}
let head = header(self.channels, self.sample_rate, self.depth, self.frames)?;
self.out.seek(SeekFrom::Start(self.start))?;
self.out.write_all(&head)?;
self.out.seek(SeekFrom::End(0))?;
self.out.flush()?;
Ok(self.out)
}
}
/// Everything before the sample data of an AIFF holding `frames` frames; its length doesn't
/// depend on `frames`.
fn header(channels: usize, sample_rate: u32, depth: BitDepth, frames: u64) -> Result<Vec<u8>> {
let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?;
let frames_u32 = u32::try_from(frames).map_err(|_| AudioError::TooLarge("too many frames for AIFF".into()))?;
let (bits, is_float): (u16, bool) = match depth {
BitDepth::Int16 => (16, false),
BitDepth::Int24 => (24, false),
BitDepth::Int32 => (32, false),
BitDepth::Float32 => (32, true),
};
let bytes = usize::from(bits / 8);
let data_len =
bytes.checked_mul(channels).and_then(|b| b.checked_mul(frames)).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let (bits, is_float) = sample_format(depth);
let data_len = u64::from(bits / 8).saturating_mul(channels as u64).saturating_mul(frames);
let mut comm = Vec::with_capacity(40);
comm.extend_from_slice(&channels_i16.to_be_bytes());
comm.extend_from_slice(&frames_u32.to_be_bytes());
comm.extend_from_slice(&bits.to_be_bytes());
comm.extend_from_slice(&u32_to_extended(buf.sample_rate));
comm.extend_from_slice(&u32_to_extended(sample_rate));
if is_float {
comm.extend_from_slice(b"fl32");
let name = b"32-bit float";
@@ -204,29 +274,10 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
head.extend_from_slice(&ssnd_len.to_be_bytes());
head.extend_from_slice(&[0u8; 8]); // offset, blockSize
let total = head.len().saturating_add(data_len).saturating_add(data_len & 1);
let total = (head.len() as u64).saturating_add(data_len).saturating_add(data_len & 1);
let form_len = u32::try_from(total - 8).map_err(|_| AudioError::TooLarge("AIFF file exceeds 4 GiB".into()))?;
if let Some(slot) = head.get_mut(4..8) {
slot.copy_from_slice(&form_len.to_be_bytes());
}
let mut out = Vec::new();
out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?;
out.extend_from_slice(&head);
let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels);
for f in 0..frames {
for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) {
let s = ch.get(f).copied().unwrap_or(0.0);
if is_float {
out.extend_from_slice(&clamp_float(s).to_be_bytes());
} else {
let v = q.quantize(s).to_be_bytes();
out.extend_from_slice(v.get(4 - bytes..).unwrap_or_default());
}
}
}
if data_len % 2 == 1 {
out.push(0);
}
Ok(out)
Ok(head)
}
+87
View File
@@ -0,0 +1,87 @@
//! Size checks on CAF chunks before symphonia reads them: it allocates whatever a magic cookie,
//! a packet table or a packet claims, and a failed allocation aborts the process.
use crate::pcm::{be_u16, be_u32, be_u64};
use crate::{AudioError, Result};
/// Refuse the CAF stream at `at` if its magic cookie, packet table or packets claim more bytes
/// than the file holds.
pub(crate) fn check(b: &[u8], at: usize) -> Result<()> {
if be_u16(b, at + 4) == Some(1) { chunks(b, at + 8) } else { Ok(()) }
}
fn malformed(what: &str) -> AudioError {
AudioError::Malformed(format!("CAF {what} is larger than the file"))
}
/// Walk the chunks the way the CAF reader reads them: it advances by the bytes it parses, not by
/// the declared size, and after a `data` chunk of unknown size it reads on into the audio.
fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
// Whether the packet table lists each packet's size and frame count (variable per packet).
let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false);
while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) {
let (body, size) = (pos + 12, size as i64);
let used = match kind {
b"desc" if size == 32 => {
lists_sizes = be_u32(b, body + 16) == Some(0);
lists_frames = be_u32(b, body + 20) == Some(0);
described = true;
Some(32)
}
b"data" if size == -1 => Some(4),
b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20),
b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?,
b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
b"desc" | b"pakt" | b"chan" => None,
_ => u64::try_from(size).ok(),
};
match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) {
Some(next) if next < b.len() => pos = next,
_ => return Ok(()),
}
}
Ok(())
}
/// Check a packet table's count and packet sizes; returns the bytes symphonia reads for it, or
/// None where symphonia stops. It reserves room for every packet before reading any, so a table
/// it would refuse after that, or that runs past the file, is refused first.
fn packet_table(b: &[u8], body: usize, lists_sizes: bool, lists_frames: bool) -> Result<Option<u64>> {
let valid = |at: usize| be_u64(b, at).filter(|&n| n as i64 >= 0);
let (Some(packets), Some(_)) = (valid(body), valid(body + 8)) else { return Ok(None) };
let mut at = body + 24;
if packets > b.len().saturating_sub(at) as u64 {
return Err(malformed("packet table"));
}
if !lists_sizes && !lists_frames {
return if packets == 0 { Ok(Some(24)) } else { Err(AudioError::Malformed("a constant bit rate CAF lists packets".into())) };
}
for _ in 0..packets {
if lists_sizes {
let Some((size, len)) = varint(b, at) else { return Err(malformed("packet table")) };
if size > b.len() as u64 {
return Err(malformed("packet"));
}
at += len;
}
if lists_frames {
let Some((_, len)) = varint(b, at) else { return Err(malformed("packet table")) };
at += len;
}
}
Ok(Some((at - body) as u64))
}
/// A CAF variable-length integer at `at` and its length in bytes.
fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
let mut v = 0u64;
for i in 0..9 {
let x = *b.get(at + i)?;
v |= u64::from(x & 0x7f);
if x & 0x80 == 0 {
return Some((v, i + 1));
}
v <<= 7;
}
None
}
+223 -42
View File
@@ -1,9 +1,10 @@
//! A small pure-Rust FLAC encoder (RFC 9639): fixed blocksize, independent channels, and per
//! subframe the cheapest of CONSTANT, VERBATIM or FIXED (orders 0–4) prediction with
//! partitioned Rice-coded residuals.
//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`].
use crate::pcm::{Quantizer, validate_buffer};
use crate::{AudioBuffer, AudioError, BitDepth, Result};
use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer};
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
use std::io::{Cursor, Seek, SeekFrom, Write};
const BLOCK_SIZE: usize = 4096;
const MAX_PARTITION_ORDER: u32 = 6;
@@ -269,31 +270,24 @@ fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) {
}
}
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
validate_buffer(buf)?;
let channels = buf.channels.len();
/// Checks that FLAC can hold the layout; returns the bits per sample.
fn check(channels: usize, sample_rate: u32, depth: BitDepth) -> Result<u32> {
crate::pcm::validate_layout(channels, sample_rate)?;
if channels > 8 {
return Err(AudioError::Unsupported(format!("FLAC supports at most 8 channels, got {channels}")));
}
if buf.sample_rate > 655_350 {
return Err(AudioError::Unsupported(format!("FLAC sample rate {} exceeds 655350 Hz", buf.sample_rate)));
if sample_rate > 655_350 {
return Err(AudioError::Unsupported(format!("FLAC sample rate {sample_rate} exceeds 655350 Hz")));
}
let bps: u32 = match depth {
BitDepth::Int16 => 16,
BitDepth::Int24 => 24,
BitDepth::Int32 => 32,
BitDepth::Float32 => return Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())),
};
let frames = buf.frames();
let total = frames as u64;
if total >= 1u64 << 36 {
return Err(AudioError::TooLarge("too many frames for FLAC".into()));
match depth {
BitDepth::Int16 => Ok(16),
BitDepth::Int24 => Ok(24),
BitDepth::Int32 => Ok(32),
BitDepth::Float32 => Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())),
}
}
let mut out = Vec::new();
out.try_reserve(frames.saturating_mul(channels).saturating_mul(bps as usize / 8) / 2)
.map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?;
out.extend_from_slice(b"fLaC");
fn stream_info(channels: usize, sample_rate: u32, bps: u32, total: u64) -> Vec<u8> {
let mut si = BitWriter::new();
si.write(1, 1); // last metadata block
si.write(0, 7); // STREAMINFO
@@ -302,7 +296,7 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
si.write(BLOCK_SIZE as u64, 16);
si.write(0, 24);
si.write(0, 24);
si.write(u64::from(buf.sample_rate), 20);
si.write(u64::from(sample_rate), 20);
si.write(channels as u64 - 1, 3);
si.write(u64::from(bps - 1), 5);
si.write(total >> 32, 4);
@@ -310,25 +304,98 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
for _ in 0..4 {
si.write(0, 32); // MD5 unknown
}
out.extend_from_slice(&si.into_bytes());
si.into_bytes()
}
let (sr_code, sr_extra) = sample_rate_code(buf.sample_rate);
let mut quantizers = Quantizer::per_channel(bps, dither, channels);
let mut block: Vec<i64> = Vec::with_capacity(BLOCK_SIZE);
let mut frame_no: u64 = 0;
let mut start = 0usize;
while start < frames {
let len = (frames - start).min(BLOCK_SIZE);
/// A FLAC stream written block by block; [`Writer::finish`] fills in the total sample count.
pub(crate) struct Writer<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
bps: u32,
quantizers: Vec<Quantizer>,
pending: Vec<Vec<i64>>,
frames: u64,
frame_no: u64,
/// Set when a frame write has failed: `write_frame` clears the pending samples before
/// writing, so a failed frame is lost while `frames` still counts it; the writer refuses
/// every later call.
failed: bool,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
let bps = check(channels, sample_rate, depth)?;
let start = out.stream_position()?;
out.write_all(b"fLaC")?;
out.write_all(&stream_info(channels, sample_rate, bps, 0))?;
Ok(Writer {
out,
start,
channels,
sample_rate,
bps,
quantizers: Quantizer::per_channel(bps, dither, channels),
pending: (0..channels).map(|_| Vec::with_capacity(BLOCK_SIZE)).collect(),
frames: 0,
frame_no: 0,
failed: false,
})
}
/// The frame count after `frames` more, or an error past what FLAC can count (2^36 frames).
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
let total = self.frames.saturating_add(frames);
if total >= 1u64 << 36 {
return Err(AudioError::TooLarge("too many frames for FLAC".into()));
}
Ok(total)
}
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
/// fit (see [`Writer::check_room`]).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
if self.failed {
return Err(crate::failed_write_error());
}
let total = self.check_room(frames as u64)?;
let mut done = 0;
while done < frames {
let held = self.pending.first().map_or(0, Vec::len);
let n = (BLOCK_SIZE - held).min(frames - done);
for (c, (p, q)) in self.pending.iter_mut().zip(self.quantizers.iter_mut()).enumerate() {
let src = block.get(c).and_then(|ch| ch.get(done..)).unwrap_or_default();
p.extend((0..n).map(|i| i64::from(q.quantize(src.get(i).copied().unwrap_or(0.0)))));
}
done += n;
if held + n == BLOCK_SIZE
&& let Err(e) = self.write_frame()
{
self.failed = true;
return Err(e);
}
}
self.frames = total;
Ok(())
}
fn write_frame(&mut self) -> Result<()> {
let len = self.pending.first().map_or(0, Vec::len);
if len == 0 {
return Ok(());
}
let (sr_code, sr_extra) = sample_rate_code(self.sample_rate);
let mut w = BitWriter::new();
w.write(0x3FFE, 14);
w.write(0, 1);
w.write(0, 1); // fixed blocksize stream
w.write(7, 4); // 16-bit (blocksize-1) follows
w.write(sr_code, 4);
w.write(channels as u64 - 1, 4);
w.write(bps_code(bps), 3);
w.write(self.channels as u64 - 1, 4);
w.write(bps_code(self.bps), 3);
w.write(0, 1);
write_utf8_number(&mut w, frame_no);
write_utf8_number(&mut w, self.frame_no);
w.write(len as u64 - 1, 16);
if let Some((v, n)) = sr_extra {
w.write(v, n);
@@ -339,19 +406,133 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
w.write(u64::from(b), 8);
}
w.write(u64::from(crc8(&header)), 8);
for (ch, q) in buf.channels.iter().zip(quantizers.iter_mut()) {
block.clear();
block.extend(ch.get(start..start + len).unwrap_or_default().iter().map(|&s| i64::from(q.quantize(s))));
write_subframe(&mut w, &block, bps);
for p in &mut self.pending {
write_subframe(&mut w, p, self.bps);
p.clear();
}
let mut frame = w.into_bytes();
let crc = crc16(&frame);
frame.extend_from_slice(&crc.to_be_bytes());
out.extend_from_slice(&frame);
start += len;
frame_no += 1;
self.out.write_all(&frame)?;
self.frame_no += 1;
Ok(())
}
Ok(out)
pub(crate) fn finish(mut self) -> Result<W> {
if self.failed {
return Err(crate::failed_write_error());
}
self.write_frame()?;
let info = stream_info(self.channels, self.sample_rate, self.bps, self.frames);
self.out.seek(SeekFrom::Start(self.start.saturating_add(4)))?;
self.out.write_all(&info)?;
self.out.seek(SeekFrom::End(0))?;
self.out.flush()?;
Ok(self.out)
}
}
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
validate_buffer(buf)?;
let bps = check(buf.channels.len(), buf.sample_rate, depth)?;
let frames = buf.frames();
let mut out = Vec::new();
out.try_reserve(frames.saturating_mul(buf.channels.len()).saturating_mul(bps as usize / 8) / 2)
.map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?;
let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?;
w.write(&buf.channels, frames)?;
Ok(w.finish()?.into_inner())
}
/// Refuse the FLAC stream at `at` if a comment or picture field claims more bytes than its
/// metadata block holds: symphonia allocates each field's length before reading it.
pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> {
let mut pos = at + 4;
while let Some(head) = be_u32(b, pos) {
let (body, len) = (pos + 4, (head & 0x00ff_ffff) as usize);
let end = (body + len).min(b.len());
let overruns = match (head >> 24) & 0x7f {
4 => comments_overrun(b, body, end),
// Picture: type, MIME type, description, four size fields, data.
6 => overruns(b, body + 4, end, be_u32, [0, 0, 16]),
_ => false,
};
if overruns {
return Err(AudioError::Malformed("a FLAC metadata field is larger than its block".into()));
}
if head >> 31 == 1 {
break;
}
pos = body + len;
}
Ok(())
}
/// The info of a FLAC stream with no audio frames at all: STREAMINFO declares 0 samples and
/// nothing follows the metadata blocks. Symphonia's reader fails on these, as it looks for a first
/// frame, so the caller answers them itself.
pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
let mut pos = at.checked_add(4)?;
let mut first = true;
loop {
let head = be_u32(b, pos)?;
let (body, len) = (pos.checked_add(4)?, (head & 0x00ff_ffff) as usize);
let end = body.checked_add(len)?;
if end > b.len() {
return None;
}
if first && ((head >> 24) & 0x7f != 0 || len != 34) {
return None;
}
first = false;
if head >> 31 == 1 {
if end != b.len() {
return None;
}
break;
}
pos = end;
}
// STREAMINFO, from the sample rate on: 20 bits rate, 3 channels - 1, 5 bits per sample - 1, 36 bits total.
let si = at.checked_add(4 + 4 + 10)?;
let packed = u64::from_be_bytes(b.get(si..si.checked_add(8)?)?.try_into().ok()?);
let sample_rate = (packed >> 44) as u32;
let channels = ((packed >> 41) & 0x7) as u16 + 1;
let bps = ((packed >> 36) & 0x1f) as u32 + 1;
if packed & 0xf_ffff_ffff != 0 || sample_rate == 0 {
return None;
}
let sample_format = match bps {
1..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
}
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
/// The vendor string is skipped unread.
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
let Some(vendor) = le_u32(b, body) else { return false };
let at = (body + 4).saturating_add(vendor as usize);
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
}
/// Whether one of the length-prefixed fields from `at` claims more than `end` leaves; `gaps` are
/// the bytes skipped before each length, which `read` reads.
fn overruns(b: &[u8], mut at: usize, end: usize, read: fn(&[u8], usize) -> Option<u32>, gaps: impl IntoIterator<Item = usize>) -> bool {
for gap in gaps {
at = at.saturating_add(gap);
let Some(n) = read(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
at += 4;
if n as usize > end - at {
return true;
}
at += n as usize;
}
false
}
#[cfg(test)]
+203 -35
View File
@@ -8,18 +8,24 @@
//! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`.
//! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in
//! encoder), with optional TPDF dither.
//! * [`wav_float_header`] heads a float WAV written while it grows (recording).
//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing.
//! * [`FileEncoder`] writes the same formats block by block into any `Write + Seek`, so a file
//! of any length never has to fit in memory.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod aiff;
mod buffer;
mod caf;
mod flac;
mod mp4;
mod pcm;
pub mod peaks;
mod symph;
mod wav;
pub use buffer::AudioBuffer;
use std::io::{Seek, Write};
/// Errors from probing, decoding and encoding.
#[derive(Debug, thiserror::Error)]
@@ -36,6 +42,9 @@ pub enum AudioError {
/// Encoding failed (e.g. an empty buffer).
#[error("audio encode failed: {0}")]
Encode(String),
/// Writing the output failed.
#[error("audio write failed: {0}")]
Io(#[from] std::io::Error),
}
/// Result alias for this crate.
@@ -52,9 +61,110 @@ pub enum FileFormat {
Aac,
Alac,
Caf,
/// Any other container `symphonia` reads: Matroska and WebM by name.
Other,
}
impl FileFormat {
/// The formats [`decode`] reads, found by extension. Contents decide when they disagree.
pub const READ: &'static [FileFormat] =
&[FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac, FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Caf, FileFormat::Other];
/// The formats [`encode`] writes.
pub const WRITE: &'static [FileFormat] = &[FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac];
/// The extensions that name this format, lower case and without the dot; the first is the one
/// a new file gets. ALAC shares AAC's MP4 container, so a `.m4a` name reads as AAC until the
/// contents say otherwise.
pub fn extensions(self) -> &'static [&'static str] {
match self {
FileFormat::Wav => &["wav", "wave", "bwf", "rf64"],
FileFormat::Aiff => &["aif", "aiff", "aifc"],
FileFormat::Flac => &["flac"],
FileFormat::Mp3 => &["mp3", "mp2", "mp1"],
FileFormat::Ogg => &["ogg", "oga"],
FileFormat::Aac => &["m4a", "m4b", "aac", "mp4", "adts"],
FileFormat::Alac => &["m4a"],
FileFormat::Caf => &["caf"],
FileFormat::Other => &["mkv", "webm"],
}
}
/// The name people know the format by, as menus show it.
pub fn name(self) -> &'static str {
match self {
FileFormat::Wav => "WAV",
FileFormat::Aiff => "AIFF",
FileFormat::Flac => "FLAC",
FileFormat::Mp3 => "MP3",
FileFormat::Ogg => "Ogg Vorbis",
FileFormat::Aac => "AAC",
FileFormat::Alac => "ALAC",
FileFormat::Caf => "CAF",
FileFormat::Other => "Other",
}
}
/// The media type a file of this format is served or downloaded as.
pub fn mime_type(self) -> &'static str {
match self {
FileFormat::Wav => "audio/wav",
FileFormat::Aiff => "audio/aiff",
FileFormat::Flac => "audio/flac",
FileFormat::Mp3 => "audio/mpeg",
FileFormat::Ogg => "audio/ogg",
FileFormat::Aac | FileFormat::Alac => "audio/mp4",
FileFormat::Caf => "audio/x-caf",
FileFormat::Other => "application/octet-stream",
}
}
/// The extension a new file of this format gets.
pub fn default_extension(self) -> &'static str {
self.extensions().first().copied().unwrap_or("bin")
}
/// The readable format an extension or format name names (`wav`, `.AIFF`; ASCII
/// case-insensitive), or `None` when nothing reads it.
pub fn from_extension(ext: &str) -> Option<FileFormat> {
let ext = ext.trim_start_matches('.');
Self::READ.iter().copied().find(|f| f.extensions().iter().any(|x| x.eq_ignore_ascii_case(ext)))
}
/// The readable format `path`'s extension names, or `None` without a known extension.
pub fn from_path(path: impl AsRef<std::path::Path>) -> Option<FileFormat> {
path.as_ref().extension().and_then(std::ffi::OsStr::to_str).and_then(Self::from_extension)
}
/// The extensions [`encode`] writes this format under: the names whose readers expect what it
/// writes. RF64 and AIFF-C names are left out because `encode` writes plain RIFF and AIFF
/// headers for them.
pub fn write_extensions(self) -> &'static [&'static str] {
match self {
FileFormat::Wav => &["wav", "wave", "bwf"],
FileFormat::Aiff => &["aif", "aiff"],
FileFormat::Flac => &["flac"],
_ => &[],
}
}
/// The format [`encode`] writes for an extension or format name (ASCII case-insensitive), or
/// `None` when it writes none.
pub fn from_write_extension(ext: &str) -> Option<FileFormat> {
let ext = ext.trim_start_matches('.');
Self::WRITE.iter().copied().find(|f| f.write_extensions().iter().any(|x| x.eq_ignore_ascii_case(ext)))
}
/// Every extension of [`FileFormat::READ`], in order: what file pickers and browsers filter by.
pub fn readable_extensions() -> Vec<&'static str> {
Self::READ.iter().flat_map(|f| f.extensions().iter().copied()).collect()
}
/// Every extension of [`FileFormat::WRITE`], in order: what save dialogs, help and errors list.
pub fn writable_extensions() -> Vec<&'static str> {
Self::WRITE.iter().flat_map(|f| f.write_extensions().iter().copied()).collect()
}
}
/// Stored sample representation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SampleFormat {
@@ -106,6 +216,7 @@ pub enum BitDepth {
Int16,
Int24,
Int32,
/// Preserve finite samples, including values above full scale; map NaN to 0 and infinities to ±1.
Float32,
}
@@ -127,14 +238,17 @@ impl Default for EncodeOptions {
}
}
/// Size of an ID3v2 tag at the start of `b`, if there is one.
/// Size of an ID3v2 tag at the start of `b`, if there is one. The 10-byte footer flag (`0x10`)
/// exists only in ID3v2.4; in v2.2/v2.3 that bit is reserved and must not extend the skip.
fn id3v2_len(b: &[u8]) -> Option<usize> {
if b.get(..3)? != b"ID3" {
return None;
}
let version = *b.get(3)?;
let flags = *b.get(5)?;
let size = b.get(6..10)?.iter().fold(0usize, |acc, &x| (acc << 7) | usize::from(x & 0x7F));
Some(10 + size + if flags & 0x10 != 0 { 10 } else { 0 })
let footer = version == 4 && flags & 0x10 != 0;
Some(10 + size + if footer { 10 } else { 0 })
}
fn detect_by_magic(b: &[u8], depth: u32) -> Option<FileFormat> {
@@ -179,22 +293,9 @@ fn detect_by_magic(b: &[u8], depth: u32) -> Option<FileFormat> {
None
}
fn detect_by_ext(ext: &str) -> FileFormat {
match ext.trim_start_matches('.').to_ascii_lowercase().as_str() {
"wav" | "wave" | "bwf" | "rf64" => FileFormat::Wav,
"aif" | "aiff" | "aifc" => FileFormat::Aiff,
"flac" => FileFormat::Flac,
"mp3" | "mp2" | "mp1" => FileFormat::Mp3,
"ogg" | "oga" => FileFormat::Ogg,
"aac" | "m4a" | "mp4" | "adts" => FileFormat::Aac,
"caf" => FileFormat::Caf,
_ => FileFormat::Other,
}
}
/// Identify a file by its magic bytes, falling back to the extension hint (`"wav"`, `".flac"`).
pub fn detect_format(bytes: &[u8], ext_hint: Option<&str>) -> FileFormat {
detect_by_magic(bytes, 0).unwrap_or_else(|| ext_hint.map(detect_by_ext).unwrap_or(FileFormat::Other))
detect_by_magic(bytes, 0).unwrap_or_else(|| ext_hint.and_then(FileFormat::from_extension).unwrap_or(FileFormat::Other))
}
/// Read a file's format, sample rate, channel count, length and BWF metadata.
@@ -204,16 +305,16 @@ pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result<AudioInfo> {
FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
wav::WavParse::Native(l) => Ok(l.info),
wav::WavParse::Foreign { bwf } => {
let mut info = symph::probe(bytes, Some("wav"), format)?;
let mut info = symph::probe(bytes, format)?;
info.bwf = bwf;
Ok(info)
}
},
FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
aiff::AiffParse::Native(l) => Ok(l.info),
aiff::AiffParse::Foreign => symph::probe(bytes, Some("aiff"), format),
aiff::AiffParse::Foreign => symph::probe(bytes, format),
},
_ => symph::probe(bytes, ext_hint, format),
_ => symph::probe(bytes, format),
}
}
@@ -229,7 +330,7 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB
Ok((info, buf))
}
wav::WavParse::Foreign { bwf } => {
let (mut info, buf) = symph::decode(bytes, Some("wav"), format)?;
let (mut info, buf) = symph::decode(bytes, format)?;
info.bwf = bwf;
Ok((info, buf))
}
@@ -241,9 +342,9 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB
info.frames = buf.frames() as u64;
Ok((info, buf))
}
aiff::AiffParse::Foreign => symph::decode(bytes, Some("aiff"), format),
aiff::AiffParse::Foreign => symph::decode(bytes, format),
},
_ => symph::decode(bytes, ext_hint, format),
_ => symph::decode(bytes, format),
}
}
@@ -257,19 +358,6 @@ pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
}
}
/// Formats [`encode`] can write, as the extensions that name them.
pub const ENCODE_EXTENSIONS: &str = "wav, aif, aiff, flac";
/// The format [`encode`] writes for a file extension or format name (`wav`, `aiff`, `flac`, case-insensitive), or `None` when it cannot write it.
pub fn encode_format_for(name: &str) -> Option<FileFormat> {
match name.trim_start_matches('.').to_ascii_lowercase().as_str() {
"wav" | "wave" | "bwf" => Some(FileFormat::Wav),
"aif" | "aiff" => Some(FileFormat::Aiff),
"flac" => Some(FileFormat::Flac),
_ => None,
}
}
/// Encode a WAV file in the RF64 layout regardless of size (normally used only above 4 GiB).
pub fn encode_wav_rf64(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true, None)
@@ -285,7 +373,87 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
}
}
/// The error a stream encoder returns for every call after one of its writes has failed: the
/// sink may already hold a prefix of the failed block, so the encoder's counters can no longer
/// describe the bytes on the sink, and later writes or `finish` must not claim success.
pub(crate) fn failed_write_error() -> AudioError {
AudioError::Encode("the stream encoder already failed on an earlier write; its output is incomplete".into())
}
/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing
/// the same bytes [`encode_with_channel_mask`] would for the whole buffer.
pub struct FileEncoder<W: Write + Seek>(Writer<W>);
enum Writer<W> {
Wav(wav::Writer<W>),
Aiff(aiff::Writer<W>),
Flac(flac::Writer<W>),
}
impl<W: Write + Seek> FileEncoder<W> {
/// Starts a file at the current position of `out`. `mask` overrides the WAV speaker mask
/// derived from the channel count.
pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option<u32>) -> Result<Self> {
let (depth, dither) = (opts.bit_depth, opts.dither);
Ok(FileEncoder(match opts.format {
FileFormat::Wav => Writer::Wav(wav::Writer::new(
out,
channels,
sample_rate,
depth,
dither,
wav::HeaderExtras { bwf: opts.bwf.as_ref(), mask, force_rf64: false },
)?),
FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?),
FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?),
other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
}))
}
/// Fails, without writing anything, when `frames` more frames would not fit the format (an
/// AIFF holds 4 GiB, a FLAC 2^36 frames).
pub fn check_room(&self, frames: u64) -> Result<()> {
match &self.0 {
Writer::Wav(w) => w.check_room(frames),
Writer::Aiff(w) => w.check_room(frames),
Writer::Flac(w) => w.check_room(frames),
}
.map(drop)
}
/// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are
/// silence), unless they don't fit (see [`FileEncoder::check_room`]). Once a write has
/// failed, the encoder is unusable: the sink may hold a prefix of the failed block, so
/// every later `write` and `finish` fails instead of completing an inconsistent file.
pub fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
match &mut self.0 {
Writer::Wav(w) => w.write(block, frames),
Writer::Aiff(w) => w.write(block, frames),
Writer::Flac(w) => w.write(block, frames),
}
}
/// Completes the headers and hands back `out`, positioned at the end of the file.
pub fn finish(self) -> Result<W> {
match self.0 {
Writer::Wav(w) => w.finish(),
Writer::Aiff(w) => w.finish(),
Writer::Flac(w) => w.finish(),
}
}
}
/// The WAVE_FORMAT_EXTENSIBLE speaker mask of a WAV file, if it has one.
pub fn wav_channel_mask(bytes: &[u8]) -> Option<u32> {
wav::read_channel_mask(bytes)
}
/// The header of a 32-bit float WAV holding `frames` frames (little-endian samples follow it). Its
/// length doesn't depend on `frames`, so a recorder can rewrite it in place as the file grows; it
/// switches to RF64 above 4 GiB.
pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Result<Vec<u8>> {
if channels == 0 || sample_rate == 0 {
return Err(AudioError::Encode("a WAV needs channels and a sample rate".into()));
}
wav::header(channels, sample_rate, (32, true), None, None, frames, false)
}
+106
View File
@@ -0,0 +1,106 @@
//! Size checks on MP4 atoms before symphonia reads them: it allocates whatever an atom, a sample
//! table or a sample claims, and a failed allocation aborts the process.
use crate::pcm::{be_u32, be_u64};
use crate::{AudioError, Result};
#[derive(Clone, Copy)]
enum Level {
Top,
Moov,
Trak,
Mdia,
Minf,
Stbl,
Udta,
Meta,
Ilst,
Tag,
}
/// Refuse the MP4 file whose top-level atoms start at `at` if an atom symphonia reads whole, a
/// sample table or a sample claims more bytes than the file holds.
pub(crate) fn check(b: &[u8], at: usize) -> Result<()> {
atoms(b, at, b.len(), Level::Top).map(|_| ())
}
fn malformed(what: &str) -> AudioError {
AudioError::Malformed(format!("MP4 {what} is larger than the file"))
}
/// Walk the atoms in `pos..end` that symphonia descends into, as it reads them. False where
/// symphonia stops reading: at a header it refuses, or after an atom that runs past `end`.
fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
// A header that starts inside the container is read whole, even past its end.
while pos < end {
let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return Ok(false) };
let head = if size == 1 { 16 } else { 8 };
// Size 0: no data, and the next atom starts at the container's end.
let len = match size {
0 => head,
1 => be_u64(b, pos + 8).unwrap_or(0),
n => u64::from(n),
};
if len < head {
return Ok(false);
}
let body = pos + head as usize;
let data_end = usize::try_from(len).ok().and_then(|n| pos.checked_add(n));
let next = if size == 0 { Some(end) } else { data_end };
let inner = data_end.map_or(b.len(), |n| n.min(b.len()));
let more = match (level, kind) {
(Level::Top, b"moov") => atoms(b, body, inner, Level::Moov)?,
(Level::Top | Level::Udta, b"meta") => atoms(b, body + 4, inner, Level::Meta)?,
(Level::Moov, b"trak") => atoms(b, body, inner, Level::Trak)?,
(Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?,
(Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?,
(Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?,
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?,
(Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?,
(Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?,
(Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?,
(Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?,
(Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?,
(Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?,
(Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => {
return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind))));
}
_ => true,
};
match next {
Some(n) if more && n <= end => pos = n,
_ => return Ok(false),
}
}
Ok(true)
}
/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for
/// all of them first. False if they run past the table's atom (`end`), where symphonia stops.
fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result<bool> {
let Some(n) = be_u32(b, count) else { return Ok(false) };
let (first, bytes) = (count + 4, u64::from(n) * entry);
if bytes > b.len().saturating_sub(first) as u64 {
return Err(malformed("sample table"));
}
Ok(first as u64 + bytes <= end as u64)
}
/// Every sample's size must fit in the file: symphonia reads each one into a buffer of that size.
/// Sizes past the atom are never used, as symphonia stops before reading a packet.
fn sample_sizes(b: &[u8], body: usize, end: usize) -> Result<bool> {
let (Some(size), Some(count)) = (be_u32(b, body + 4), be_u32(b, body + 8)) else { return Ok(false) };
let file = b.len() as u64;
if u64::from(size) > file {
return Err(malformed("sample"));
}
if size != 0 {
return Ok(true);
}
let fits = table(b, body + 8, 4, end)?;
let (sizes, _) = b.get(body + 12..end).unwrap_or_default().as_chunks::<4>();
if sizes.iter().take(count as usize).any(|s| u64::from(u32::from_be_bytes(*s)) > file) {
return Err(malformed("sample"));
}
Ok(fits)
}
+111 -8
View File
@@ -1,7 +1,8 @@
//! Byte-level PCM helpers shared by the WAV, AIFF and FLAC code: bounded readers, sample
//! decoding to `f32`, and quantization (with optional TPDF dither) for encoding.
use crate::{AudioError, Result};
use crate::{AudioError, BitDepth, Result};
use std::io::Write;
/// Maximum number of decoded `f32` samples (all channels) we will produce: 4 GiB of output.
pub(crate) const MAX_DECODE_SAMPLES: u64 = (4u64 << 30) / 4;
@@ -36,6 +37,12 @@ pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option<u32> {
Some(u32::from_be_bytes(s))
}
/// Big-endian `u64` at `pos`.
pub(crate) fn be_u64(b: &[u8], pos: usize) -> Option<u64> {
let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?;
Some(u64::from_be_bytes(s))
}
/// Four-byte tag at `pos`.
pub(crate) fn tag(b: &[u8], pos: usize) -> Option<[u8; 4]> {
b.get(pos..pos.checked_add(4)?)?.try_into().ok()
@@ -152,25 +159,121 @@ impl Quantizer {
v += self.uniform() - self.uniform();
}
let max = scale - 1.0;
v.round().clamp(-scale, max) as i32
round_half_away(v.clamp(-scale, max)) as i32
}
}
/// Clamp a float sample to [-1, 1], mapping NaN to 0.
pub(crate) fn clamp_float(x: f32) -> f32 {
if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) }
#[inline]
fn round_half_away(v: f64) -> f64 {
let t = v as i64;
let f = v - t as f64;
(t + i64::from(f >= 0.5) - i64::from(f <= -0.5)) as f64
}
/// Bits per sample at `depth`, and whether they are float.
pub(crate) fn sample_format(depth: BitDepth) -> (u16, bool) {
match depth {
BitDepth::Int16 => (16, false),
BitDepth::Int24 => (24, false),
BitDepth::Int32 => (32, false),
BitDepth::Float32 => (32, true),
}
}
/// Interleaves `frames` frames of planar audio (missing samples are silence) as `bits`-bit
/// samples and writes them to `out`, a few thousand frames at a time through `scratch`.
pub(crate) fn write_interleaved(
out: &mut impl Write,
scratch: &mut Vec<u8>,
block: &[Vec<f32>],
frames: usize,
quantizers: &mut [Quantizer],
(bits, is_float): (u16, bool),
big_endian: bool,
) -> std::io::Result<()> {
let size = usize::from(bits / 8);
let mut start = 0;
while start < frames {
let end = frames.min(start + 4096);
scratch.clear();
for f in start..end {
for (c, q) in quantizers.iter_mut().enumerate() {
let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0);
match (is_float, big_endian) {
(true, false) => scratch.extend_from_slice(&sanitize_float(s).to_le_bytes()),
(true, true) => scratch.extend_from_slice(&sanitize_float(s).to_be_bytes()),
(false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()),
(false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()),
}
}
}
out.write_all(scratch)?;
start = end;
}
Ok(())
}
/// Preserve finite float samples, mapping NaN to 0 and infinities to their sign (±1).
fn sanitize_float(x: f32) -> f32 {
if x.is_finite() {
x
} else if x.is_nan() {
0.0
} else {
x.signum()
}
}
/// Check that a buffer is encodable: at least one channel and a non-zero sample rate.
pub(crate) fn validate_buffer(buf: &crate::AudioBuffer) -> Result<()> {
if buf.channels.is_empty() {
validate_layout(buf.channels.len(), buf.sample_rate)
}
/// Check that a stream is encodable: at least one channel and a non-zero sample rate.
pub(crate) fn validate_layout(channels: usize, sample_rate: u32) -> Result<()> {
if channels == 0 {
return Err(AudioError::Encode("buffer has no channels".into()));
}
if buf.channels.len() > usize::from(u16::MAX) {
if channels > usize::from(u16::MAX) {
return Err(AudioError::Encode("too many channels".into()));
}
if buf.sample_rate == 0 {
if sample_rate == 0 {
return Err(AudioError::Encode("sample rate is 0".into()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_half_away_matches_f64_round_on_the_quantizer_range() {
let edges = [
0.0,
-0.0,
0.5,
-0.5,
1.5,
-1.5,
2.5,
0.499_999_999_999_999_94,
-0.499_999_999_999_999_94,
8_388_607.5,
-8_388_608.0,
2_147_483_647.0,
-2_147_483_648.0,
];
let mut x = 0x2545_f491_4f6c_dd1du64;
let spread = (0..10_000).map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
(x >> 11) as f64 / (1u64 << 53) as f64 * 4_294_967_295.0 - 2_147_483_648.0
});
for v in edges.into_iter().chain(spread) {
assert!(round_half_away(v) == v.round(), "{v}");
assert_eq!(round_half_away(v) as i32, v.round() as i32, "{v}");
}
}
}
+186 -110
View File
@@ -1,48 +1,62 @@
//! Multi-resolution min/max overview for waveform drawing.
//! Multi-resolution overview for waveform drawing.
//!
//! Level 0 holds the (min, max) of each block of [`BLOCK`] samples; each following level merges
//! [`FANOUT`] blocks of the level below, until a level has a single block. Range queries combine
//! the coarsest whole blocks that fit inside the range, so a query costs O(log n) regardless of
//! zoom.
//! Level 0 sums up each block of [`BLOCK`] samples (min, max and sum of squares); each following
//! level merges [`FANOUT`] blocks of the level below, until a level has a single block. Range
//! queries combine the coarsest whole blocks that fit inside the range and read the partial blocks
//! at its edges from the samples, so a query is exact and costs O(log n) regardless of zoom.
/// Samples per level-0 block.
pub const BLOCK: usize = 32;
/// Blocks of level `n` merged into one block of level `n + 1`.
pub const FANOUT: usize = 4;
/// Upper bound on the number of columns [`Peaks::columns`] will produce.
/// Upper bound on the number of spans [`Peaks::spans`] will produce.
pub const MAX_COLUMNS: usize = 1 << 20;
/// Multi-resolution min/max overview for waveform drawing. Level 0 = blocks of 32 samples; each next level merges 4.
/// Multi-resolution overview of one channel for waveform drawing.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Peaks {
len: usize,
levels: Vec<Vec<(f32, f32)>>,
levels: Vec<Vec<Block>>,
}
const EMPTY: (f32, f32) = (f32::INFINITY, f32::NEG_INFINITY);
fn merge(a: (f32, f32), b: (f32, f32)) -> (f32, f32) {
(a.0.min(b.0), a.1.max(b.1))
/// What a run of samples sums up to; NaN samples count for nothing.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
struct Block {
min: f32,
max: f32,
squares: f32,
}
fn finish(acc: (f32, f32)) -> (f32, f32) {
if acc.0 > acc.1 { (0.0, 0.0) } else { acc }
impl Block {
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
fn point(s: f32) -> Block {
Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } }
}
fn of(samples: &[f32]) -> Block {
samples.iter().fold(Block::EMPTY, |b, &s| b.merge(Block::point(s)))
}
fn merge(self, o: Block) -> Block {
Block { min: self.min.min(o.min), max: self.max.max(o.max), squares: self.squares + o.squares }
}
/// (min, max), or (0, 0) when nothing was summed up.
fn range(self) -> (f32, f32) {
if self.min > self.max { (0.0, 0.0) } else { (self.min, self.max) }
}
}
impl Peaks {
/// Build the overview of `samples` (one channel). NaN samples are ignored.
pub fn build(samples: &[f32]) -> Peaks {
let mut levels = Vec::new();
let level0: Vec<(f32, f32)> = samples.chunks(BLOCK).map(|c| finish(c.iter().fold(EMPTY, |acc, &s| merge(acc, (s, s))))).collect();
if level0.is_empty() {
return Peaks { len: 0, levels };
if samples.is_empty() {
return Peaks::default();
}
levels.push(level0);
while let Some(last) = levels.last() {
if last.len() <= 1 {
break;
}
let next: Vec<(f32, f32)> = last.chunks(FANOUT).map(|c| c.iter().fold(EMPTY, |acc, &b| merge(acc, b))).collect();
let mut levels: Vec<Vec<Block>> = vec![samples.chunks(BLOCK).map(Block::of).collect()];
while let Some(last) = levels.last().filter(|l| l.len() > 1) {
let next = last.chunks(FANOUT).map(|c| c.iter().fold(Block::EMPTY, |a, &b| a.merge(b))).collect();
levels.push(next);
}
Peaks { len: samples.len(), levels }
@@ -58,65 +72,84 @@ impl Peaks {
self.levels.len()
}
/// (min, max) over the sample range `[start, end)`, clamped to the data. Exact when both ends
/// are multiples of 32 (or `end` is the data length); otherwise the partial blocks at the edges
/// contribute their whole block's extremes, so the result always contains the true range.
/// Returns `(0.0, 0.0)` for an empty range.
pub fn minmax(&self, start: usize, end: usize) -> (f32, f32) {
let end = end.min(self.len);
if start >= end {
return (0.0, 0.0);
/// (min, max) over the sample range `[start, end)`, clamped to the data. `samples` must be the
/// data the overview was built from. Returns `(0.0, 0.0)` for an empty range.
pub fn minmax(&self, samples: &[f32], start: usize, end: usize) -> (f32, f32) {
self.sum(samples, start, end).range()
}
fn sum(&self, samples: &[f32], start: usize, end: usize) -> Block {
let end = end.min(self.len).min(samples.len());
let (first, last) = (start.div_ceil(BLOCK), end / BLOCK);
let raw = |a: usize, b: usize| Block::of(samples.get(a..b).unwrap_or_default());
if first >= last {
return raw(start, end);
}
let mut lo = start / BLOCK;
let mut hi = end.div_ceil(BLOCK);
let mut acc = EMPTY;
let mut lvl = 0usize;
while lo < hi {
let Some(level) = self.levels.get(lvl) else { break };
let has_parent = lvl + 1 < self.levels.len();
if !has_parent || hi - lo < 2 * FANOUT {
for b in level.get(lo..hi.min(level.len())).unwrap_or_default() {
acc = merge(acc, *b);
}
raw(start, first * BLOCK).merge(raw(last * BLOCK, end)).merge(self.blocks(first, last))
}
/// The level-0 blocks `[lo, hi)`, summed from the coarsest blocks that fit.
fn blocks(&self, mut lo: usize, mut hi: usize) -> Block {
let mut acc = Block::EMPTY;
for (lvl, level) in self.levels.iter().enumerate() {
let mut add = |i: usize| acc = acc.merge(level.get(i).copied().unwrap_or(Block::EMPTY));
if lvl + 1 == self.levels.len() || hi.saturating_sub(lo) < 2 * FANOUT {
(lo..hi).for_each(add);
break;
}
while !lo.is_multiple_of(FANOUT) && lo < hi {
acc = merge(acc, level.get(lo).copied().unwrap_or(EMPTY));
while !lo.is_multiple_of(FANOUT) {
add(lo);
lo += 1;
}
while !hi.is_multiple_of(FANOUT) && hi > lo {
while !hi.is_multiple_of(FANOUT) {
hi -= 1;
acc = merge(acc, level.get(hi).copied().unwrap_or(EMPTY));
add(hi);
}
lo /= FANOUT;
hi /= FANOUT;
lvl += 1;
}
finish(acc)
acc
}
/// Per-column (min, max) for drawing `cols` pixel columns spanning the sample range
/// `[start, end)`. Columns that fall outside the data are `(0.0, 0.0)`. Each column covers at
/// least one sample, so zoomed-in views repeat the containing sample's block.
pub fn columns(&self, start: f64, end: f64, cols: usize) -> Vec<(f32, f32)> {
let cols = cols.min(MAX_COLUMNS);
let mut out = vec![(0.0, 0.0); cols];
if cols == 0 || !start.is_finite() || !end.is_finite() || end <= start || self.len == 0 {
return out;
}
let span = (end - start) / cols as f64;
let len = self.len as f64;
for (c, slot) in out.iter_mut().enumerate() {
let s = start + span * c as f64;
let e = start + span * (c + 1) as f64;
if e <= 0.0 || s >= len {
continue;
}
let si = s.max(0.0).floor();
let ei = e.min(len).ceil().max(si + 1.0);
*slot = self.minmax(si as usize, ei as usize);
}
out
/// The spans of sample positions `[start + c * step, start + (c + 1) * step + overlap]` that
/// reach the data, clamped to it.
fn windows(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> impl Iterator<Item = Option<(f64, f64)>> {
let n = self.len.min(samples.len());
let valid = n > 0 && start.is_finite() && step.is_finite() && step > 0.0 && overlap.is_finite() && overlap >= 0.0;
let last = n.saturating_sub(1) as f64;
(0..cols.min(MAX_COLUMNS)).map(move |c| {
let (a, b) = (start + step * c as f64, start + step * (c + 1) as f64 + overlap);
(valid && b >= 0.0 && a <= last).then(|| (a.max(0.0), b.min(last)))
})
}
/// (min, max) of the line through `samples` over each of `cols` spans of sample positions
/// `[start + c * step, start + (c + 1) * step + overlap]`; fractional positions interpolate
/// between samples. Neighbouring spans share at least their boundary, so a waveform drawn from
/// them stays connected at any zoom. `None` for spans outside the data.
pub fn spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec<Option<(f32, f32)>> {
let at = |t: f64| {
let i = t.floor();
let a = samples.get(i as usize).copied().unwrap_or(0.0);
let b = samples.get(i as usize + 1).copied().unwrap_or(a);
Block::point(a + (b - a) * (t - i) as f32)
};
self.windows(samples, start, step, overlap, cols)
.map(|w| w.map(|(a, b)| self.sum(samples, a.ceil() as usize, b.floor() as usize + 1).merge(at(a)).merge(at(b)).range()))
.collect()
}
/// The RMS of `samples` over each span [`Peaks::spans`] gives, including the samples just
/// outside a span's ends.
pub fn rms_spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec<Option<f32>> {
self.windows(samples, start, step, overlap, cols)
.map(|w| {
w.map(|(a, b)| {
let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len()));
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt()
})
})
.collect()
}
}
@@ -134,29 +167,19 @@ mod tests {
}
#[test]
fn aligned_ranges_are_exact() {
fn every_range_is_exact() {
let x = signal(100_003);
let p = Peaks::build(&x);
for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003)] {
assert_eq!(p.minmax(a, b), brute(&x, a, b), "range {a}..{b}");
for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003), (5, 6), (31, 33), (1000, 1031)] {
assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}");
}
}
#[test]
fn unaligned_ranges_contain_truth_and_match_block_cover() {
let x = signal(50_000);
let p = Peaks::build(&x);
let mut seed = 12345u64;
for _ in 0..2000 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let a = (seed >> 33) as usize % 50_000;
let a = (seed >> 33) as usize % 100_003;
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let b = a + 1 + (seed >> 33) as usize % (50_000 - a);
let got = p.minmax(a, b);
let truth = brute(&x, a, b);
assert!(got.0 <= truth.0 && got.1 >= truth.1, "{a}..{b}: {got:?} vs {truth:?}");
let cover = brute(&x, a / BLOCK * BLOCK, (b.div_ceil(BLOCK) * BLOCK).min(x.len()));
assert_eq!(got, cover, "{a}..{b}");
let b = a + 1 + (seed >> 33) as usize % (100_003 - a);
assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}");
}
}
@@ -164,34 +187,87 @@ mod tests {
fn degenerate_inputs() {
let p = Peaks::build(&[]);
assert_eq!(p.len_samples(), 0);
assert_eq!(p.minmax(0, 10), (0.0, 0.0));
assert_eq!(p.columns(0.0, 10.0, 4), vec![(0.0, 0.0); 4]);
let p = Peaks::build(&[0.5, -0.25]);
assert_eq!(p.minmax(0, 2), (-0.25, 0.5));
assert_eq!(p.minmax(5, 3), (0.0, 0.0));
assert_eq!(p.minmax(0, usize::MAX), (-0.25, 0.5));
assert!(p.columns(f64::NAN, 1.0, 3).iter().all(|&c| c == (0.0, 0.0)));
assert_eq!(p.columns(0.0, 1.0, 0).len(), 0);
let p = Peaks::build(&[f32::NAN, 1.0]);
assert_eq!(p.minmax(0, 2), (1.0, 1.0));
assert_eq!(p.minmax(&[], 0, 10), (0.0, 0.0));
assert_eq!(p.spans(&[], 0.0, 1.0, 0.0, 4), vec![None; 4]);
let x = [0.5, -0.25];
let p = Peaks::build(&x);
assert_eq!(p.minmax(&x, 0, 2), (-0.25, 0.5));
assert_eq!(p.minmax(&x, 5, 3), (0.0, 0.0));
assert_eq!(p.minmax(&x, 0, usize::MAX), (-0.25, 0.5));
assert_eq!(p.minmax(&x[..1], 0, 2), (0.5, 0.5));
for (start, step, overlap) in [
(f64::NAN, 1.0, 0.0),
(0.0, 0.0, 0.0),
(0.0, -1.0, 0.0),
(0.0, f64::INFINITY, 0.0),
(f64::NEG_INFINITY, 1.0, 0.0),
(0.0, 1.0, -0.5),
(0.0, 1.0, f64::NAN),
] {
assert_eq!(p.spans(&x, start, step, overlap, 3), vec![None; 3]);
}
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 0).len(), 0);
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, usize::MAX).len(), MAX_COLUMNS);
let x = [f32::NAN, 1.0];
let p = Peaks::build(&x);
assert_eq!(p.minmax(&x, 0, 2), (1.0, 1.0));
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 1), vec![Some((1.0, 1.0))]);
assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite));
}
#[test]
fn columns_cover_range() {
fn rms_spans_match_the_samples() {
let x = signal(20_000);
let p = Peaks::build(&x);
let rms = |s: usize, e: usize| (x[s..e].iter().map(|&v| f64::from(v) * f64::from(v)).sum::<f64>() / (e - s) as f64).sqrt() as f32;
for (step, overlap) in [(1.5, 0.0), (37.0, 37.0), (1000.0, 0.0), (4096.0, 512.0)] {
let cols = ((19_999.0 - overlap) / step) as usize;
for (c, got) in p.rms_spans(&x, 0.0, step, overlap, cols).into_iter().enumerate() {
let (a, b) = (c as f64 * step, ((c + 1) as f64 * step + overlap).min(19_999.0));
let (got, want) = (got.unwrap(), rms(a.floor() as usize, (b.ceil() as usize + 1).min(20_000)));
assert!((got - want).abs() <= want * 1e-4 + 1e-6, "span {c} at step {step}: {got} vs {want}");
}
}
let sine: Vec<f32> = (0..48_000).map(|i| (i as f32 * 0.01).sin()).collect();
let p = Peaks::build(&sine);
let r = p.rms_spans(&sine, 0.0, 47_000.0, 0.0, 1)[0].unwrap();
assert!((r - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3, "{r}");
assert_eq!(p.rms_spans(&sine, f64::NAN, 1.0, 0.0, 2), vec![None, None]);
assert_eq!(p.rms_spans(&sine, 50_000.0, 1.0, 0.0, 1), vec![None]);
}
#[test]
fn spans_cover_their_samples_and_touch_their_neighbours() {
let x = signal(10_000);
let p = Peaks::build(&x);
let cols = p.columns(0.0, 10_000.0, 100);
assert_eq!(cols.len(), 100);
for (c, &(lo, hi)) in cols.iter().enumerate() {
let truth = brute(&x, c * 100, c * 100 + 100);
assert!(lo <= truth.0 && hi >= truth.1);
for step in [0.3, 1.0, 7.5, 100.0, 333.3] {
let cols = (9_999.0 / step) as usize;
let spans = p.spans(&x, 0.0, step, 0.0, cols);
for (c, s) in spans.iter().enumerate() {
let (lo, hi) = s.unwrap_or_else(|| panic!("span {c} at step {step}"));
let (a, b) = ((c as f64 * step).ceil() as usize, ((c + 1) as f64 * step).floor() as usize);
if a <= b {
let truth = brute(&x, a, b + 1);
assert!(lo <= truth.0 && hi >= truth.1, "span {c} at step {step}");
}
if let Some(Some((nlo, nhi))) = spans.get(c + 1) {
assert!(*nlo <= hi && *nhi >= lo, "spans {c} and {} at step {step} don't touch", c + 1);
}
}
let all = spans.iter().flatten().fold((f32::INFINITY, f32::NEG_INFINITY), |a, s| (a.0.min(s.0), a.1.max(s.1)));
assert_eq!(all, brute(&x, 0, ((cols as f64 * step).floor() as usize + 1).min(10_000)), "step {step}");
}
let all = cols.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |a, &c| (a.0.min(c.0), a.1.max(c.1)));
assert_eq!(all, brute(&x, 0, 10_000));
// Past the end of the data.
let cols = p.columns(9_000.0, 11_000.0, 2);
assert_eq!(cols[1], (0.0, 0.0));
// Zoomed in further than one sample per column.
assert_eq!(p.columns(0.0, 4.0, 8).len(), 8);
}
#[test]
fn spans_follow_the_line_between_samples() {
let x = [0.0, 1.0, -1.0, 0.5];
let p = Peaks::build(&x);
assert_eq!(p.spans(&x, 0.0, 0.25, 0.0, 4), vec![Some((0.0, 0.25)), Some((0.25, 0.5)), Some((0.5, 0.75)), Some((0.75, 1.0))]);
assert_eq!(p.spans(&x, 0.5, 1.0, 0.0, 1), vec![Some((0.0, 1.0))]);
let spans = vec![None, Some((0.0, 0.0)), Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5)), Some((0.5, 0.5)), None];
assert_eq!(p.spans(&x, -2.0, 1.0, 0.0, 7), spans);
assert_eq!(p.spans(&x, 0.0, 1.0, 1.0, 3), vec![Some((-1.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]);
assert_eq!(p.spans(&x, 0.0, 1.0, 0.5, 3), vec![Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]);
}
}
+583 -88
View File
@@ -1,24 +1,68 @@
//! Decode path for everything that isn't native PCM WAV/AIFF, backed by `symphonia`.
use std::io::Cursor;
use std::io::{Cursor, Seek, SeekFrom};
use std::panic::{AssertUnwindSafe, catch_unwind};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{
CODEC_TYPE_AAC, CODEC_TYPE_ADPCM_G722, CODEC_TYPE_ADPCM_G726, CODEC_TYPE_ADPCM_G726LE, CODEC_TYPE_ADPCM_IMA_QT, CODEC_TYPE_ADPCM_IMA_WAV,
CODEC_TYPE_ADPCM_MS, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP1, CODEC_TYPE_MP2, CODEC_TYPE_MP3, CODEC_TYPE_NULL, CODEC_TYPE_OPUS,
CODEC_TYPE_PCM_ALAW, CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE, CODEC_TYPE_PCM_MULAW,
CODEC_TYPE_VORBIS, CodecParameters, CodecType, DecoderOptions,
use symphonia::core::codecs::audio::well_known::{
CODEC_ID_AAC, CODEC_ID_ADPCM_G722, CODEC_ID_ADPCM_G726, CODEC_ID_ADPCM_G726LE, CODEC_ID_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_WAV, CODEC_ID_ADPCM_MS,
CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP1, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_ALAW, CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE,
CODEC_ID_PCM_F64BE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_MULAW, CODEC_ID_VORBIS,
};
use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO};
use symphonia::core::errors::Error as SymError;
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions};
use symphonia::core::formats::probe::{Hint, ProbeOptions, ProbeableFormat, Score, Scoreable};
use symphonia::core::formats::{FormatOptions, FormatReader, Track};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ScopedStream};
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use crate::pcm::MAX_DECODE_SAMPLES;
use crate::pcm::{MAX_DECODE_SAMPLES, be_u32};
use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat};
/// The most frames a packet may declare: decoders allocate a packet's frames up front, and ALAC
/// takes its frame length from the file (encoders use 4096).
const MAX_PACKET_FRAMES: u64 = 1 << 20;
/// How far [`first_marker`] looks for the first format marker: the same 1 MiB a default symphonia
/// probe scans.
const MAX_PREFIX_SCAN: usize = 1 << 20;
/// How many MPEG/ADTS candidates [`first_marker`] may hand to the readers' expensive `Scoreable`
/// implementations. A crafted 1 MiB prefix of repeated sync markers otherwise asks for hundreds
/// of thousands of scores, each allowed up to symphonia's 16 KiB score depth. Once the budget is
/// spent, the scan stops at the next audio candidate: a container-like byte string later in an
/// unscored audio payload must not become authoritative. A container marker reached first is still
/// checked; an unchecked one the probe selects is refused after probing.
const MAX_AUDIO_SCORE_ATTEMPTS: usize = 256;
/// A per-scan budget for `Scoreable::score` calls. Taking a slot consumes it even when the score
/// rejects the candidate, so a stream of false syncs cannot multiply the work.
struct ScoreBudget {
used: usize,
}
impl ScoreBudget {
/// Start a scan with no expensive score attempts spent.
fn new() -> Self {
Self { used: 0 }
}
/// Spend one score attempt, returning false once the per-scan budget is exhausted.
fn take(&mut self) -> bool {
if self.exhausted() {
return false;
}
self.used = self.used.saturating_add(1);
true
}
/// Whether no attempt is left for this scan.
fn exhausted(&self) -> bool {
self.used >= MAX_AUDIO_SCORE_ATTEMPTS
}
}
/// Turn a symphonia error into the audio layer's error, keeping "unsupported" and "too large"
/// distinct from a malformed file.
fn map_err(e: SymError) -> AudioError {
match e {
SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()),
@@ -32,54 +76,346 @@ fn guarded<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into())))
}
fn open(bytes: &[u8], ext_hint: Option<&str>) -> Result<Box<dyn FormatReader>> {
let mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
let mut hint = Hint::new();
if let Some(ext) = ext_hint {
hint.with_extension(ext.trim_start_matches('.'));
}
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
let probed = symphonia::default::get_probe().format(&hint, mss, &fmt_opts, &MetadataOptions::default()).map_err(map_err)?;
Ok(probed.format)
/// What [`open`] found: a reader, or a stream that holds no audio frames and has no reader.
enum Opened {
Reader(Box<dyn FormatReader>),
Empty(AudioInfo),
}
fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat {
/// Decoder options for every compressed format. Gapless playback used to be a
/// `FormatOptions` flag in symphonia 0.5; in 0.6 the decoder option (on by default) trims the
/// encoder delay and padding that the demuxer reports on each packet.
fn decoder_options() -> AudioDecoderOptions {
AudioDecoderOptions::default().gapless(true)
}
/// The first byte after any ID3v2 tags at the start of `b`. Symphonia's tag reader allocates
/// whatever a frame claims, so the tags are skipped, not read: they are not used here.
fn skip_id3v2(b: &[u8]) -> usize {
let mut at = 0usize;
loop {
match b.get(at..).and_then(crate::id3v2_len) {
Some(n) if n > 0 => at = at.saturating_add(n),
_ => return at,
}
}
}
/// The first format marker the probe can reach from `start`, within its 1 MiB scan limit.
#[derive(Clone, Copy)]
enum FirstMarker {
Flac(usize),
Caf(usize),
Mp4(usize),
Wav(usize),
/// MPEG audio, Ogg, Matroska or AIFF: formats with no separate container size check.
Audio,
}
/// What the preflight container checks covered before the probe built a reader.
enum Precheck {
/// A zero-frame FLAC: returned without a reader, because Symphonia 0.6's reader needs a first
/// audio frame to resynchronize to.
Empty(AudioInfo),
/// The size checks ran for the container the probe reports under this short name.
Checked(&'static str),
/// No container size check was needed.
Unchecked,
}
/// Probe short names whose demuxer allocates from sizes in the file, so the matching container
/// check has to run before the stream is decoded.
const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"];
/// The container markers to check for the family `detected` names. `None` is a raw audio stream
/// (MP3, Ogg, AIFF), whose payload may contain container-like bytes: the unrestricted scan below
/// finds a container only when one starts the stream, before any audio marker.
fn container_markers(detected: FileFormat) -> Option<&'static [&'static [u8; 4]]> {
match detected {
// RF64 and BW64 are the >4 GiB RIFF forms.
FileFormat::Wav => Some(&[b"RIFF", b"RF64", b"BW64"]),
FileFormat::Flac => Some(&[b"fLaC"]),
FileFormat::Caf => Some(&[b"caff"]),
// MP4/M4A put `ftyp` after the first atom's size field.
FileFormat::Aac | FileFormat::Alac => Some(&[b"ftyp"]),
// Unknown files may carry junk before the container; symphonia's probe scans for markers.
FileFormat::Other => Some(&[b"caff", b"ftyp", b"fLaC", b"RIFF"]),
FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => None,
}
}
/// The probe short name a container marker stands for.
fn expected_probe_format(marker: &[u8; 4]) -> &'static str {
match marker {
b"caff" => "caf",
// The isomp4 reader reads the `ftyp` atom.
b"ftyp" => "isomp4",
b"fLaC" => "flac",
// RIFF, RF64 and BW64.
_ => "wave",
}
}
/// Find the first marker of `markers` at or after `at`, within symphonia's probe depth. A RIFF
/// form other than WAVE is skipped, as the probe skips it and reads a WAVE behind it.
fn find_container_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> {
let end = bytes.len().min(at.saturating_add(MAX_PREFIX_SCAN));
for pos in at..end.saturating_sub(3) {
let window = bytes.get(pos..pos + 4)?;
if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) {
if matches!(*marker, b"RIFF" | b"RF64" | b"BW64") && bytes.get(pos + 8..pos + 12) != Some(&b"WAVE"[..]) {
continue;
}
return Some((pos, *marker));
}
}
None
}
/// The scan variant for a marker found by [`find_container_marker`], with the probe short name its
/// size check stands for. `ftyp` sits four bytes into its atom, so the MP4 check starts there.
fn marker_kind(at: usize, marker: &[u8; 4]) -> (FirstMarker, &'static str) {
match marker {
b"fLaC" => (FirstMarker::Flac(at), expected_probe_format(marker)),
b"caff" => (FirstMarker::Caf(at), expected_probe_format(marker)),
b"ftyp" => (FirstMarker::Mp4(at.saturating_sub(4)), expected_probe_format(marker)),
_ => (FirstMarker::Wav(at), expected_probe_format(marker)),
}
}
/// The probe short name a container scan variant stands for.
fn marker_name(marker: FirstMarker) -> &'static str {
match marker {
FirstMarker::Flac(_) => "flac",
FirstMarker::Caf(_) => "caf",
FirstMarker::Mp4(_) => "isomp4",
FirstMarker::Wav(_) => "wave",
FirstMarker::Audio => "",
}
}
/// Whether `rest` starts with one of `R`'s exact registered probe markers.
fn has_probe_marker<R: ProbeableFormat<'static>>(rest: &[u8]) -> bool {
R::probe_data().iter().any(|data| data.spec.markers.iter().any(|marker| rest.starts_with(marker)))
}
/// Score `R` at `at` exactly as symphonia's private probe does: `Supported` wins, EOF and every
/// other scoring error mean "not this format", while a real I/O error aborts the probe.
fn scores_supported<R: Scoreable>(stream: &mut MediaSourceStream<'_>, at: usize, depth: u16) -> Result<bool> {
stream.seek(SeekFrom::Start(u64::try_from(at).unwrap_or(u64::MAX)))?;
match R::score(ScopedStream::new(stream, u64::from(depth))) {
Ok(Score::Supported(_)) => Ok(true),
Err(SymError::IoError(e)) if e.kind() != std::io::ErrorKind::UnexpectedEof => Err(AudioError::Malformed(e.to_string())),
_ => Ok(false),
}
}
/// Whether `b[at..]` is an MPEG/ADTS candidate the probe would actually select: an exact
/// `probe_data()` marker and a `Supported` score from the same reader. A loose `0xFFEx` pair is
/// not enough: `0xFFE0` is neither a registered marker nor a valid frame, so it must not stop the
/// scan before a later FLAC/CAF/MP4 candidate.
fn audio_sync_at<'a>(b: &'a [u8], at: usize, stream: &mut Option<MediaSourceStream<'a>>, budget: &mut ScoreBudget) -> Result<bool> {
let rest = b.get(at..).unwrap_or_default();
let adts = has_probe_marker::<symphonia::default::formats::AdtsReader<'static>>(rest);
let mpa = has_probe_marker::<symphonia::default::formats::MpaReader<'static>>(rest);
if !adts && !mpa {
return Ok(false);
}
if stream.is_none() {
*stream = Some(MediaSourceStream::new(Box::new(Cursor::new(b)), MediaSourceStreamOptions::default()));
}
let Some(mss) = stream.as_mut() else { return Ok(false) };
let depth = ProbeOptions::default().max_score_depth;
if adts && budget.take() && scores_supported::<symphonia::default::formats::AdtsReader<'static>>(mss, at, depth)? {
return Ok(true);
}
if mpa && budget.take() && scores_supported::<symphonia::default::formats::MpaReader<'static>>(mss, at, depth)? {
return Ok(true);
}
Ok(false)
}
/// Find the first marker that can start a container or audio stream. Symphonia's `Probe::probe`
/// constructs the reader itself and exposes no pre-construction hook, while checking every magic
/// in the scan window would validate bytes the probe never reaches (for example a valid MP3's
/// audio payload). Stopping at the first marker avoids that: it is the candidate the probe selects
/// for FLAC, CAF, MP4, Ogg and Matroska, and for WAV/AIFF once their full form is verified below.
/// MPEG/ADTS candidates are checked with the readers' own `probe_data()`/`Scoreable` APIs, so a
/// false sync byte pair cannot stop the scan. If that scoring budget runs out, the scan stops at
/// the next audio candidate instead of trusting a container-like byte string later in the unscored
/// audio payload. A container marker reached before any such candidate is still preflighted; if
/// the probe selects a size-checked container the scan did not check, `open` refuses it.
///
/// WAV candidates include RF64/BW64 so `check_info` can run, although symphonia 0.6 registers only
/// the RIFF marker. Native PCM RF64/BW64 is parsed by `wav::parse` before this path; a foreign
/// RF64/BW64 stream therefore remains unsupported by the probe rather than misdetected.
fn first_marker(b: &[u8], start: usize) -> Result<Option<FirstMarker>> {
first_marker_with_budget(b, start, &mut ScoreBudget::new())
}
/// [`first_marker`] with an explicit score budget, so a test can prove a prefix crowded with
/// false sync markers performs only the bounded number of reader scores.
fn first_marker_with_budget(b: &[u8], start: usize, budget: &mut ScoreBudget) -> Result<Option<FirstMarker>> {
let end = b.len().min(start.saturating_add(MAX_PREFIX_SCAN));
let mut at = start;
let mut audio_probe = None;
while at.saturating_add(4) <= end {
let rest = b.get(at..).unwrap_or_default();
if rest.get(..4) == Some(b"fLaC".as_slice()) {
return Ok(Some(FirstMarker::Flac(at)));
}
if rest.get(..4) == Some(b"caff".as_slice()) {
return Ok(Some(FirstMarker::Caf(at)));
}
if rest.get(..4) == Some(b"OggS".as_slice()) || rest.get(..4) == Some(b"\x1A\x45\xDF\xA3".as_slice()) || crate::aiff::is_aiff(rest) {
return Ok(Some(FirstMarker::Audio));
}
if crate::wav::is_wav(rest) {
return Ok(Some(FirstMarker::Wav(at)));
}
if let (Some(&b0), Some(&b1)) = (b.get(at), b.get(at + 1))
&& b0 == 0xFF
&& b1 & 0xE0 == 0xE0
{
// A candidate after the cap cannot be scored. Stop rather than let a container-like
// byte string later in an unscored audio payload become preflight-authoritative; the
// probe will select the reader, and `open` refuses an unchecked size-checked one.
if budget.exhausted() {
return Ok(None);
}
if audio_sync_at(b, at, &mut audio_probe, budget)? {
return Ok(Some(FirstMarker::Audio));
}
}
if rest.get(4..8) == Some(b"ftyp".as_slice()) {
return Ok(Some(FirstMarker::Mp4(at)));
}
at += 1;
}
Ok(None)
}
/// Run the early size checks on the container the probe can select. `start` is the first byte
/// after leading ID3v2 tags. A zero-frame FLAC is returned as an empty stream because Symphonia
/// 0.6's reader requires a first audio frame to resynchronize to.
fn precheck(b: &[u8], start: usize, detected: FileFormat) -> Result<Precheck> {
let any_marker = |b: &[u8], start: usize| -> Result<Option<(FirstMarker, &'static str)>> {
Ok(match first_marker(b, start)? {
// Stop at the first marker of any kind, so container magic incidental to an audio
// payload is not mistaken for the stream's container.
Some(FirstMarker::Audio) | None => None,
Some(marker) => Some((marker, marker_name(marker))),
})
};
let found = match container_markers(detected) {
// Follow the detection: check the first marker of the family it names, so a misleading
// marker cannot let a differently sized container reach the probe unchecked. A detection
// is only a hint, though: when the stream holds none of its markers (a FLAC behind a
// `.mp3` name), the probe can still select another container, which is checked here.
Some(markers) => match find_container_marker(b, start, markers) {
Some((at, marker)) => Some(marker_kind(at, marker)),
None => any_marker(b, start)?,
},
None => any_marker(b, start)?,
};
let Some((marker, expected)) = found else { return Ok(Precheck::Unchecked) };
match marker {
FirstMarker::Flac(at) => {
crate::flac::check_metadata(b, at)?;
if let Some(info) = crate::flac::empty_stream_info(b, at) {
return Ok(Precheck::Empty(info));
}
Ok(Precheck::Checked(expected))
}
FirstMarker::Caf(at) => {
crate::caf::check(b, at)?;
Ok(Precheck::Checked(expected))
}
FirstMarker::Mp4(at) => {
crate::mp4::check(b, at)?;
Ok(Precheck::Checked(expected))
}
FirstMarker::Wav(at) => {
crate::wav::check_info(b, at)?;
Ok(Precheck::Checked(expected))
}
FirstMarker::Audio => Ok(Precheck::Unchecked),
}
}
/// Skip leading ID3v2 tags, run the container size preflight, then probe with symphonia; a zero-frame FLAC found by
/// the preflight comes back as [`Opened::Empty`] without probing. A size-checked container is refused when probing
/// selects a different reader or one that was never checked, so incidental payload magic cannot bypass those limits.
fn open(bytes: &[u8], detected: FileFormat) -> Result<Opened> {
let start = skip_id3v2(bytes);
if start > 0 && start >= bytes.len() {
return Err(AudioError::Malformed("ID3v2 tag extends past the end of the file".into()));
}
let checked = precheck(bytes, start, detected)?;
if let Precheck::Empty(info) = checked {
return Ok(Opened::Empty(info));
}
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
// Seek (rather than pre-positioning the cursor) so the stream's absolute position is `start`
// too: container offsets such as MP4's `stco` are absolute file offsets.
mss.seek(SeekFrom::Start(u64::try_from(start).unwrap_or(u64::MAX)))?;
let reader: Box<dyn FormatReader> =
symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
let probed = reader.format_info().short_name;
match checked {
// Only containers whose readers allocate from sizes in the file need the check. A stream
// the probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a
// marker found in its audio data must not reject it.
Precheck::Checked(expected) if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) && probed != expected => {
return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran")));
}
Precheck::Unchecked if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) => {
return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
}
_ => {}
}
Ok(Opened::Reader(reader))
}
fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat {
if detected != FileFormat::Other {
return detected;
}
match codec {
c if c == CODEC_TYPE_FLAC => FileFormat::Flac,
c if c == CODEC_TYPE_MP3 || c == CODEC_TYPE_MP2 || c == CODEC_TYPE_MP1 => FileFormat::Mp3,
c if c == CODEC_TYPE_AAC => FileFormat::Aac,
c if c == CODEC_TYPE_ALAC => FileFormat::Alac,
c if c == CODEC_TYPE_VORBIS => FileFormat::Ogg,
c if c == CODEC_ID_FLAC => FileFormat::Flac,
c if c == CODEC_ID_MP3 || c == CODEC_ID_MP2 || c == CODEC_ID_MP1 => FileFormat::Mp3,
c if c == CODEC_ID_AAC => FileFormat::Aac,
c if c == CODEC_ID_ALAC => FileFormat::Alac,
c if c == CODEC_ID_VORBIS => FileFormat::Ogg,
_ => FileFormat::Other,
}
}
fn sample_format_of(p: &CodecParameters) -> SampleFormat {
/// The stored sample format, using the codec and its declared bit depth. Lossy and unusual
/// codecs decode to `Compressed`.
fn sample_format_of(p: &AudioCodecParameters) -> SampleFormat {
let c = p.codec;
if c == CODEC_TYPE_PCM_F32LE || c == CODEC_TYPE_PCM_F32BE {
if c == CODEC_ID_PCM_F32LE || c == CODEC_ID_PCM_F32BE {
return SampleFormat::Float32;
}
if c == CODEC_TYPE_PCM_F64LE || c == CODEC_TYPE_PCM_F64BE {
if c == CODEC_ID_PCM_F64LE || c == CODEC_ID_PCM_F64BE {
return SampleFormat::Float64;
}
let lossy = [
CODEC_TYPE_MP1,
CODEC_TYPE_MP2,
CODEC_TYPE_MP3,
CODEC_TYPE_AAC,
CODEC_TYPE_VORBIS,
CODEC_TYPE_OPUS,
CODEC_TYPE_ADPCM_MS,
CODEC_TYPE_ADPCM_IMA_WAV,
CODEC_TYPE_ADPCM_IMA_QT,
CODEC_TYPE_ADPCM_G722,
CODEC_TYPE_ADPCM_G726,
CODEC_TYPE_ADPCM_G726LE,
CODEC_TYPE_PCM_ALAW,
CODEC_TYPE_PCM_MULAW,
CODEC_ID_MP1,
CODEC_ID_MP2,
CODEC_ID_MP3,
CODEC_ID_AAC,
CODEC_ID_VORBIS,
CODEC_ID_OPUS,
CODEC_ID_ADPCM_MS,
CODEC_ID_ADPCM_IMA_WAV,
CODEC_ID_ADPCM_IMA_QT,
CODEC_ID_ADPCM_G722,
CODEC_ID_ADPCM_G726,
CODEC_ID_ADPCM_G726LE,
CODEC_ID_PCM_ALAW,
CODEC_ID_PCM_MULAW,
];
let lossless = !lossy.contains(&c);
match (lossless, p.bits_per_sample) {
@@ -91,15 +427,21 @@ fn sample_format_of(p: &CodecParameters) -> SampleFormat {
}
}
/// The audio parameters of `track`, if it carries any.
fn audio_params(track: &Track) -> Option<&AudioCodecParameters> {
track.codec_params.as_ref()?.audio()
}
/// The first track with a decodable audio codec, with the stream information the UI shows.
fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> {
let track = reader
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.find(|t| audio_params(t).is_some_and(|p| p.codec != CODEC_ID_NULL_AUDIO))
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
let p = &track.codec_params;
let p = audio_params(track).ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?;
let channels = p.channels.map(|c| c.count()).unwrap_or(0);
let channels = p.channels.as_ref().map(|c| c.count()).unwrap_or(0);
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
Ok((
track.id,
@@ -108,72 +450,105 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
sample_format: sample_format_of(p),
sample_rate,
channels,
frames: p.n_frames.unwrap_or(0),
frames: track.num_frames.unwrap_or(0),
bwf: None,
},
))
}
/// A demux error ends the decode; it is only an error when nothing was decoded yet.
fn demux_failure(frames: u64, e: SymError) -> Result<()> {
if frames == 0 {
return Err(map_err(e));
}
log::warn!("stopping decode after demux error: {e}");
Ok(())
}
/// Note a corrupt packet (the `errors`-th); a stream that never decodes is malformed.
fn corrupt_packet(errors: usize, frames: u64, msg: &str) -> Result<()> {
log::debug!("skipping corrupt packet: {msg}");
if errors > 1000 && frames == 0 {
return Err(AudioError::Malformed(format!("no decodable packets: {msg}")));
}
Ok(())
}
/// The decoded stream is authoritative for the channel count: record it and allocate the planes.
fn init_planar(info: &mut AudioInfo, ch: usize) -> Result<Vec<Vec<f32>>> {
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
Ok((0..ch).map(|_| Vec::new()).collect())
}
/// Append one interleaved block to the planes, replacing non-finite samples with silence.
fn append_interleaved(planar: &mut [Vec<f32>], interleaved: &[f32], ch: usize) {
for frame in interleaved.chunks_exact(ch) {
for (dst, &s) in planar.iter_mut().zip(frame) {
dst.push(if s.is_finite() { s } else { 0.0 });
}
}
}
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
let mut reader = open(bytes, ext_hint)?;
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
let mut reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader,
Opened::Empty(info) => {
let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0);
return Ok((info, buf));
}
};
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) {
let track = reader.tracks().iter().find(|t| t.id == track_id).ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
if let Some(n) = track.num_frames {
let total = n.saturating_mul(u64::from(info.channels.max(1)));
if !count_only && total > MAX_DECODE_SAMPLES {
return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
}
}
let params = reader
.tracks()
.iter()
.find(|t| t.id == track_id)
.map(|t| t.codec_params.clone())
.ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
let mut decoder = symphonia::default::get_codecs().make(&params, &DecoderOptions::default()).map_err(map_err)?;
let params = audio_params(track).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
}
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(&params, &decoder_options()).map_err(map_err)?;
let mut planar: Vec<Vec<f32>> = Vec::new();
let mut interleaved: Vec<f32> = Vec::new();
let mut frames: u64 = 0;
let mut sbuf: Option<SampleBuffer<f32>> = None;
let mut errors = 0usize;
loop {
let packet = match reader.next_packet() {
Ok(p) => p,
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => break,
Err(e) => {
if frames == 0 {
return Err(map_err(e));
}
log::warn!("stopping decode after demux error: {e}");
demux_failure(frames, e)?;
break;
}
};
if packet.track_id() != track_id {
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(SymError::DecodeError(m)) => {
errors += 1;
log::debug!("skipping corrupt packet: {m}");
if errors > 1000 && frames == 0 {
return Err(AudioError::Malformed(format!("no decodable packets: {m}")));
}
corrupt_packet(errors, frames, m)?;
continue;
}
Err(SymError::IoError(_)) => break,
Err(e) => return Err(map_err(e)),
};
let spec = *decoded.spec();
let ch = spec.channels.count();
// The interleaved copy below is laid out by planes: use their count so a decoder whose
// channel spec disagrees with its planes cannot trip the copy's length assertion.
let ch = decoded.num_planes();
if ch == 0 {
continue;
}
if planar.is_empty() {
// The decoded stream is authoritative for the channel count.
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
planar = (0..ch).map(|_| Vec::new()).collect();
planar = init_planar(&mut info, ch)?;
}
let n = decoded.frames() as u64;
frames = frames.saturating_add(n);
@@ -183,17 +558,16 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b
if count_only {
continue;
}
let needed = decoded.capacity() as u64;
let sb = match &mut sbuf {
Some(sb) if sb.capacity() >= decoded.capacity() * ch => sb,
_ => sbuf.insert(SampleBuffer::<f32>::new(needed, spec)),
};
sb.copy_interleaved_ref(decoded);
for frame in sb.samples().chunks_exact(ch) {
for (dst, &s) in planar.iter_mut().zip(frame) {
dst.push(if s.is_finite() { s } else { 0.0 });
}
let needed = usize::try_from(n)
.ok()
.and_then(|n| n.checked_mul(ch))
.ok_or_else(|| AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into()))?;
if interleaved.len() < needed {
interleaved.try_reserve(needed - interleaved.len()).map_err(|_| AudioError::TooLarge("cannot allocate decoded audio".into()))?;
interleaved.resize(needed, 0.0);
}
decoded.copy_to_slice_interleaved(&mut interleaved[..needed]);
append_interleaved(&mut planar, &interleaved[..needed], ch);
}
if info.channels == 0 {
return Err(AudioError::Malformed("stream contains no audio".into()));
@@ -206,18 +580,139 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b
Ok((info, AudioBuffer { sample_rate, channels: planar }))
}
pub(crate) fn probe(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<AudioInfo> {
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
guarded(|| {
let reader = open(bytes, ext_hint)?;
let reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader,
Opened::Empty(info) => return Ok(info),
};
let (_, info) = info_of(reader.as_ref(), detected)?;
if info.frames > 0 && info.channels > 0 {
return Ok(info);
}
// Length not in the header (e.g. MP3 without a Xing frame): count by decoding.
run(bytes, ext_hint, detected, true).map(|(i, _)| i)
run(bytes, detected, true).map(|(i, _)| i)
})
}
pub(crate) fn decode(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
guarded(|| run(bytes, ext_hint, detected, false))
pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
guarded(|| run(bytes, detected, false))
}
#[cfg(test)]
mod tests {
use super::*;
use symphonia::core::codecs::audio::well_known::{
CODEC_ID_AAC, CODEC_ID_ADPCM_MS, CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP3, CODEC_ID_PCM_F32LE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_S16LE,
CODEC_ID_VORBIS,
};
/// Build codec parameters for a test, optionally declaring a bit depth.
fn params(codec: AudioCodecId, bits_per_sample: Option<u32>) -> AudioCodecParameters {
let mut params = AudioCodecParameters::new();
params.for_codec(codec);
if let Some(bits) = bits_per_sample {
params.with_bits_per_sample(bits);
}
params
}
/// Codec ids map to their reported formats, while a format detected from the container wins over the codec.
#[test]
fn codecs_map_to_the_reported_formats() {
assert_eq!(format_from_codec(CODEC_ID_FLAC, FileFormat::Other), FileFormat::Flac);
assert_eq!(format_from_codec(CODEC_ID_MP3, FileFormat::Other), FileFormat::Mp3);
assert_eq!(format_from_codec(CODEC_ID_AAC, FileFormat::Other), FileFormat::Aac);
assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Other), FileFormat::Alac);
assert_eq!(format_from_codec(CODEC_ID_VORBIS, FileFormat::Other), FileFormat::Ogg);
// The container detected from the bytes wins over the codec.
assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Aac), FileFormat::Aac);
}
/// PCM and FLAC keep their declared bit depth; lossy codecs report `Compressed` regardless of their decoded
/// width.
#[test]
fn sample_formats_keep_their_depth_and_lossy_marker() {
assert_eq!(sample_format_of(&params(CODEC_ID_PCM_S16LE, Some(16))), SampleFormat::Int16);
assert_eq!(sample_format_of(&params(CODEC_ID_FLAC, Some(24))), SampleFormat::Int24);
assert_eq!(sample_format_of(&params(CODEC_ID_FLAC, Some(8))), SampleFormat::Int8);
assert_eq!(sample_format_of(&params(CODEC_ID_FLAC, Some(32))), SampleFormat::Int32);
assert_eq!(sample_format_of(&params(CODEC_ID_PCM_F32LE, Some(32))), SampleFormat::Float32);
assert_eq!(sample_format_of(&params(CODEC_ID_PCM_F64LE, Some(64))), SampleFormat::Float64);
// Lossy codecs report no fixed bit depth, whatever their decoded sample format is.
assert_eq!(sample_format_of(&params(CODEC_ID_AAC, Some(16))), SampleFormat::Compressed);
assert_eq!(sample_format_of(&params(CODEC_ID_ADPCM_MS, Some(4))), SampleFormat::Compressed);
assert_eq!(sample_format_of(&params(CODEC_ID_ALAC, None)), SampleFormat::Compressed);
}
/// Decoder options keep gapless trimming enabled so the encoder delay and padding reported by the demuxer are
/// removed.
#[test]
fn gapless_decoding_stays_enabled() {
assert!(decoder_options().gapless);
}
/// A complete MPEG frame header is a candidate; loose or unregistered sync pairs are rejected before scoring,
/// while a registered but truncated header is rejected by the reader's score after consuming a budget slot.
#[test]
fn only_the_readers_own_audio_markers_are_candidates() {
/// Whether the preflight would score `b` as an audio candidate at offset 0.
fn candidate(b: &[u8]) -> bool {
let mut stream = None;
let mut budget = ScoreBudget::new();
matches!(audio_sync_at(b, 0, &mut stream, &mut budget), Ok(true))
}
let mut mp3_frame = vec![0u8; 417];
mp3_frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz
assert!(candidate(&[&mp3_frame[..], &mp3_frame].concat()));
for false_sync in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] {
assert!(!candidate(&false_sync), "{false_sync:?} is not a probe candidate");
}
}
/// A prefix made of false MPEG sync markers must not ask the readers to score every byte
/// position: the per-scan budget caps the expensive work. A cheap container marker reached
/// before the next audio candidate is still found, but once the cap is spent an audio
/// candidate stops the scan so later payload magic cannot be preflight-authoritative.
#[test]
fn a_prefix_crowded_with_sync_markers_scores_only_the_budget() {
let mut crowded = vec![0u8; 1 << 16];
for pair in crowded.as_chunks_mut::<2>().0 {
pair.copy_from_slice(&[0xFF, 0xE2]);
}
let mut budget = ScoreBudget::new();
assert!(matches!(first_marker_with_budget(&crowded, 0, &mut budget), Ok(None)));
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS, "no candidate after the cap is scored");
// At the cap boundary, with no later audio candidate, the cheap marker is still found.
let mut at_cap: Vec<u8> = (0..MAX_AUDIO_SCORE_ATTEMPTS).flat_map(|_| [0xFFu8, 0xE2]).collect();
at_cap.extend_from_slice(b"fLaC");
let mut budget = ScoreBudget::new();
assert!(
matches!(first_marker_with_budget(&at_cap, 0, &mut budget), Ok(Some(FirstMarker::Flac(_)))),
"a container marker reached before the next audio candidate is still found"
);
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS);
// One more false candidate between the cap and the magic makes the scan stop unchecked:
// the same magic could instead sit in a valid audio payload.
let mut after_candidate = at_cap[..2 * MAX_AUDIO_SCORE_ATTEMPTS].to_vec();
after_candidate.extend_from_slice(&[0xFF, 0xE2]);
after_candidate.extend_from_slice(b"fLaC");
let mut budget = ScoreBudget::new();
assert!(matches!(first_marker_with_budget(&after_candidate, 0, &mut budget), Ok(None)), "an audio candidate after the cap stops the scan");
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS);
}
/// ID3v2 sizes are read in their 7-bit syncsafe form; consecutive tags are skipped by their declared sizes, and
/// the v2.3 reserved footer bit does not extend a tag.
#[test]
fn leading_id3_tags_are_skipped_by_their_declared_size() {
let tag = [&b"ID3\x04\0\0\0\0\0\x05"[..], b"hello"].concat();
assert_eq!(skip_id3v2(&[&tag[..], &tag[..]].concat()), 30);
assert_eq!(skip_id3v2(b"fLaC"), 0);
// The 10-byte footer flag exists only in v2.4; the same bit in v2.3 is reserved.
assert_eq!(crate::id3v2_len(b"ID3\x03\0\x10\0\0\0\0"), Some(10));
assert_eq!(crate::id3v2_len(b"ID3\x04\0\x10\0\0\0\0"), Some(20));
}
}
+267 -62
View File
@@ -1,14 +1,17 @@
//! Native WAV / BWF / RF64 reader and writer (RIFF chunk layout per the Microsoft multimedia
//! specification, `bext` per EBU Tech 3285, `ds64` per EBU Tech 3306).
use crate::pcm::{PcmKind, Quantizer, clamp_float, deinterleave, le_u16, le_u32, le_u64, tag, validate_buffer};
use crate::pcm::{PcmKind, Quantizer, deinterleave, le_u16, le_u32, le_u64, sample_format, tag, validate_buffer, validate_layout, write_interleaved};
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, BwfInfo, FileFormat, Result, SampleFormat};
use std::io::{Cursor, Seek, SeekFrom, Write};
const WAVE_FORMAT_PCM: u16 = 1;
const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
/// Tail of the KSDATAFORMAT_SUBTYPE_* GUIDs (bytes 2..16); bytes 0..2 hold the format tag.
const GUID_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
/// Tail of the Ambisonic subtype GUIDs {0000000x-0721-11D3-8644-C8C1CA000000}: PCM (x = 1) and float (x = 3) as well.
const AMBISONIC_TAIL: [u8; 14] = [0x00, 0x00, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0x00, 0x00, 0x00];
const BEXT_MIN_LEN: usize = 602;
/// Parsed WAV layout (no sample data decoded yet).
@@ -49,13 +52,25 @@ fn parse_fmt(c: &[u8]) -> Result<Fmt> {
fmt.valid_bits = fmt.bits;
if fmt.tag == WAVE_FORMAT_EXTENSIBLE {
// cbSize(2) validBits(2) channelMask(4) subFormat GUID(16)
if let (Some(valid), Some(sub)) = (le_u16(c, 18), le_u16(c, 24)) {
if valid != 0 {
fmt.valid_bits = valid;
}
fmt.tag = sub;
} else {
let (Some(cb_size), Some(valid), Some(sub)) = (le_u16(c, 16), le_u16(c, 18), le_u16(c, 24)) else {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
};
let Some(tail) = c.get(26..40) else {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
};
if cb_size < 22 {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE cbSize is below 22".into()));
}
if valid > fmt.bits {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE valid bits exceed the container size".into()));
}
if valid != 0 {
fmt.valid_bits = valid;
}
// A subtype outside the KSDATAFORMAT_SUBTYPE_* family is not PCM/float, whatever its first two bytes say:
// keep the extensible tag so the file goes to the foreign decoder.
if tail == GUID_TAIL || tail == AMBISONIC_TAIL {
fmt.tag = sub;
}
}
Ok(fmt)
@@ -83,11 +98,125 @@ fn parse_bext(c: &[u8]) -> Option<BwfInfo> {
})
}
/// Refuse the WAVE file at `at`, which symphonia reads (A-law, ADPCM, ...), if an INFO entry
/// claims more bytes than the file holds: symphonia allocates each entry's length. Chunks are
/// walked where symphonia reads them: after the bytes a chunk's parser used, not its length.
pub(crate) fn check_info(b: &[u8], at: usize) -> Result<()> {
if tag(b, at + 8) != Some(*b"WAVE") {
return Ok(());
}
let mut pos = at + 12;
while let Some(id) = tag(b, pos) {
let Some(len) = le_u32(b, pos + 4).map(|n| n as usize) else { break };
let (body, pad) = (pos + 8, len & 1);
let used = match &id {
b"data" => break,
b"fmt " => fmt_used(b, body, len),
b"LIST" if len < 4 => break,
b"LIST" => {
let entries = if tag(b, body) == Some(*b"INFO") { info_entries(b, body + 4, len - 4)? } else { 0 };
// The list pads itself, then the chunk is padded again.
4 + entries.max(len - 4) + pad
}
_ => len,
};
pos = body.saturating_add(used).saturating_add(pad);
}
Ok(())
}
/// The bytes symphonia reads of a `fmt ` chunk: some formats' parsers go by their own extra size.
fn fmt_used(b: &[u8], body: usize, len: usize) -> usize {
match le_u16(b, body) {
Some(2 | 6 | 7 | 0x11) => le_u16(b, body + 16).map_or(len, |extra| 18 + usize::from(extra)),
Some(0xfffe) => 40,
_ => len,
}
}
/// Check the INFO entries in the `len` bytes from `start`; returns the bytes symphonia reads of
/// them, with the pad it reads after an odd entry.
fn info_entries(b: &[u8], start: usize, len: usize) -> Result<usize> {
let mut used = 0;
while used + 8 <= len {
let Some(n) = le_u32(b, start + used + 4).map(|n| n as usize) else { break };
if n > b.len().saturating_sub(start + used + 8) {
return Err(AudioError::Malformed("a WAV INFO entry is larger than the file".into()));
}
used += 8 + n;
used += used & 1;
}
Ok(used)
}
/// True when the bytes start like a RIFF/RF64/BW64 WAVE file.
pub(crate) fn is_wav(b: &[u8]) -> bool {
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
}
/// True when everything from `from` to the end of the file is whole RIFF chunks (four printable
/// ASCII id bytes, a size that fits, the pad byte after an odd size; the last chunk's pad may be
/// missing): the metadata of a finished file, not samples.
fn only_chunks_follow(b: &[u8], from: usize) -> bool {
let mut pos = from;
while pos < b.len() {
let (Some(id), Some(size)) = (tag(b, pos), le_u32(b, pos + 4)) else { return false };
if !id.iter().all(|c| (0x20..0x7f).contains(c)) {
return false;
}
let size = size as usize;
let Some(end) = pos.checked_add(8).and_then(|body| body.checked_add(size)) else { return false };
if end > b.len() {
return false;
}
let next = end + (size & 1);
if end == b.len() || next == b.len() {
return true;
}
pos = next;
}
false
}
/// A `data` chunk header as found in the file, for [`data_chunk_len`].
struct DataChunk {
body: usize,
size32: u32,
is_rf64: bool,
remaining: usize,
declared: usize,
}
/// Bytes of audio a `data` chunk holds: leniently, whatever is present when the size is wrong or
/// the recording was never finalised.
fn data_chunk_len(b: &[u8], c: DataChunk) -> usize {
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
let empty = c.size32 == 0 && !c.is_rf64 && finished && only_chunks_follow(b, c.body);
if c.size32 == 0 && !c.is_rf64 && !empty { c.remaining } else { c.declared.min(c.remaining) }
}
/// The sample layout of a `fmt` chunk, `None` for a format we do not read natively.
fn pcm_layout(fmt: &Fmt) -> Result<Option<(PcmKind, SampleFormat)>> {
Ok(Some(match (fmt.tag, fmt.bits) {
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
let bytes = usize::from(bits.div_ceil(8));
let sf = match fmt.valid_bits.min(bits) {
0..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
}
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
}
_ => return Ok(None),
}))
}
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
if !is_wav(b) {
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
@@ -116,7 +245,10 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
if &id == b"data" {
// Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still
// yields whatever audio is actually present.
let len = if size32 == 0 && !is_rf64 { remaining } else { declared.min(remaining) };
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
// that was never finalised, and what follows is audio.
let len = data_chunk_len(b, DataChunk { body, size32, is_rf64, remaining, declared });
if data.is_none() {
data = Some((body, len));
}
@@ -151,24 +283,7 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
return Err(AudioError::Malformed("WAV declares a zero sample rate".into()));
}
let channels = usize::from(fmt.channels);
let (kind, sample_format) = match (fmt.tag, fmt.bits) {
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
let bytes = usize::from(bits.div_ceil(8));
let sf = match fmt.valid_bits.min(bits) {
0..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
}
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
}
_ => return Ok(WavParse::Foreign { bwf }),
};
let Some((kind, sample_format)) = pcm_layout(&fmt)? else { return Ok(WavParse::Foreign { bwf }) };
let block = kind.bytes().checked_mul(channels).ok_or_else(|| AudioError::Malformed("block size overflow".into()))?;
if usize::from(fmt.block_align) != block {
log::debug!("WAV block_align {} disagrees with computed {block}; using computed", fmt.block_align);
@@ -255,6 +370,99 @@ fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
}
}
/// A WAV written block by block: a placeholder header first, then the real one (the same length,
/// RF64 above 4 GiB, or always when `force_rf64`) written over it by [`Writer::finish`].
pub(crate) struct Writer<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
format: (u16, bool),
bwf: Option<BwfInfo>,
mask: Option<u32>,
force_rf64: bool,
quantizers: Vec<Quantizer>,
frames: u64,
bytes: Vec<u8>,
/// Set when a block write has failed: the sink may hold a prefix of that block while
/// `frames` does not count it, so the writer refuses every later call.
failed: bool,
}
/// The optional header parts of a WAV file: BWF metadata, speaker mask, and whether to force RF64.
#[derive(Clone, Copy)]
pub(crate) struct HeaderExtras<'a> {
pub(crate) bwf: Option<&'a BwfInfo>,
pub(crate) mask: Option<u32>,
pub(crate) force_rf64: bool,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool, extras: HeaderExtras<'_>) -> Result<Self> {
let HeaderExtras { bwf, mask, force_rf64 } = extras;
validate_layout(channels, sample_rate)?;
let format = sample_format(depth);
let start = out.stream_position()?;
out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?;
let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels);
Ok(Writer {
out,
start,
channels,
sample_rate,
format,
bwf: bwf.cloned(),
mask,
force_rf64,
quantizers,
frames: 0,
bytes: Vec::new(),
failed: false,
})
}
fn header(&self, frames: u64) -> Result<Vec<u8>> {
header(self.channels, self.sample_rate, self.format, self.bwf.as_ref(), self.mask, frames, self.force_rf64)
}
/// The frame count after `frames` more (RF64 has room for any bounce).
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
let total = self.frames.saturating_add(frames);
self.header(total)?;
Ok(total)
}
/// Appends `frames` frames of planar audio (missing samples are silence).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
if self.failed {
return Err(crate::failed_write_error());
}
let total = self.check_room(frames as u64)?;
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false) {
self.failed = true;
return Err(AudioError::from(e));
}
self.frames = total;
Ok(())
}
pub(crate) fn finish(mut self) -> Result<W> {
if self.failed {
return Err(crate::failed_write_error());
}
let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
if data_len % 2 == 1 {
self.out.write_all(&[0])?;
}
let head = self.header(self.frames)?;
self.out.seek(SeekFrom::Start(self.start))?;
self.out.write_all(&head)?;
self.out.seek(SeekFrom::End(0))?;
self.out.flush()?;
Ok(self.out)
}
}
/// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests);
/// it is used automatically when the file would exceed 4 GiB.
/// `mask` overrides the speaker mask derived from the channel count.
@@ -269,24 +477,38 @@ pub(crate) fn encode(
validate_buffer(buf)?;
let channels = buf.channels.len();
let frames = buf.frames();
let (bits, is_float): (u16, bool) = match depth {
BitDepth::Int16 => (16, false),
BitDepth::Int24 => (24, false),
BitDepth::Int32 => (32, false),
BitDepth::Float32 => (32, true),
};
let bytes = usize::from(bits / 8);
let block = bytes.checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
let format = sample_format(depth);
let head = header(channels, buf.sample_rate, format, bwf, mask, frames as u64, force_rf64)?;
let data_len = (usize::from(format.0 / 8) * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let mut out = Vec::new();
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
.map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?;
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, HeaderExtras { bwf, mask, force_rf64 })?;
w.write(&buf.channels, frames)?;
Ok(w.finish()?.into_inner())
}
/// Everything before the sample data of a WAV holding `frames` frames.
pub(crate) fn header(
channels: usize,
sample_rate: u32,
(bits, is_float): (u16, bool),
bwf: Option<&BwfInfo>,
mask: Option<u32>,
frames: u64,
force_rf64: bool,
) -> Result<Vec<u8>> {
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
let data_len = block.checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
let mut fmt = Vec::with_capacity(40);
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
fmt.extend_from_slice(&buf.sample_rate.to_le_bytes());
fmt.extend_from_slice(&buf.sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
fmt.extend_from_slice(&sample_rate.to_le_bytes());
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
fmt.extend_from_slice(&block_align.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
if extensible {
@@ -308,7 +530,10 @@ pub(crate) fn encode(
if is_float {
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
}
let total = 4 + (8 + rf64_body_len) + meta.len() + 8 + data_len + (data_len & 1);
let total = ((4 + (8 + rf64_body_len) + meta.len() + 8) as u64)
.checked_add(data_len)
.and_then(|t| t.checked_add(data_len & 1))
.ok_or_else(|| AudioError::TooLarge("WAV size overflow".into()))?;
let rf64 = force_rf64 || u32::try_from(total).is_err();
if rf64 {
@@ -316,9 +541,9 @@ pub(crate) fn encode(
head.extend_from_slice(&u32::MAX.to_le_bytes());
head.extend_from_slice(b"WAVE");
let mut ds64 = Vec::with_capacity(rf64_body_len);
ds64.extend_from_slice(&(total as u64).to_le_bytes());
ds64.extend_from_slice(&(data_len as u64).to_le_bytes());
ds64.extend_from_slice(&(frames as u64).to_le_bytes());
ds64.extend_from_slice(&total.to_le_bytes());
ds64.extend_from_slice(&data_len.to_le_bytes());
ds64.extend_from_slice(&frames.to_le_bytes());
ds64.extend_from_slice(&0u32.to_le_bytes());
push_chunk(&mut head, b"ds64", &ds64);
} else {
@@ -330,25 +555,5 @@ pub(crate) fn encode(
head.extend_from_slice(&meta);
head.extend_from_slice(b"data");
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
let mut out = Vec::new();
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
.map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?;
out.extend_from_slice(&head);
let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels);
for f in 0..frames {
for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) {
let s = ch.get(f).copied().unwrap_or(0.0);
if is_float {
out.extend_from_slice(&clamp_float(s).to_le_bytes());
} else {
let v = q.quantize(s).to_le_bytes();
out.extend_from_slice(v.get(..bytes).unwrap_or_default());
}
}
}
if data_len % 2 == 1 {
out.push(0);
}
Ok(out)
Ok(head)
}
-13
View File
@@ -1,13 +0,0 @@
use soundcraft_audio_io::{FileFormat, encode_format_for};
#[test]
fn only_formats_the_encoder_writes_are_named() {
assert_eq!(encode_format_for("wav"), Some(FileFormat::Wav));
assert_eq!(encode_format_for("WAV"), Some(FileFormat::Wav));
assert_eq!(encode_format_for(".aif"), Some(FileFormat::Aiff));
assert_eq!(encode_format_for("aiff"), Some(FileFormat::Aiff));
assert_eq!(encode_format_for("flac"), Some(FileFormat::Flac));
for other in ["mp3", "ogg", "m4a", "xyz", ""] {
assert_eq!(encode_format_for(other), None, "{other}");
}
}
+17
View File
@@ -0,0 +1,17 @@
Tiny fixtures for `tests/it/decode_read_list.rs`: one per container or codec the READ list names
that the crate cannot write itself. Made with ffmpeg 8.0 from a 0.1 s mono 440 Hz sine
(`F="-y -f lavfi -i sine=frequency=440:duration=0.1 -ac 1"`):
ffmpeg $F -ar 8000 -c:a libmp3lame -b:a 16k tone.mp3
ffmpeg $F -ar 24000 -c:a mp2 -b:a 32k tone.mp2
ffmpeg $F -ar 8000 -c:a libvorbis -q:a -1 tone.ogg
ffmpeg $F -ar 8000 -c:a aac -b:a 16k tone.m4a
ffmpeg $F -ar 8000 -c:a aac -b:a 16k -f adts tone.aac
ffmpeg $F -ar 8000 -c:a pcm_s16le tone.caf
ffmpeg $F -ar 8000 -c:a pcm_s16le tone.mkv
ffmpeg $F -ar 8000 -c:a libvorbis -q:a -1 tone.webm
ffmpeg $F -ar 8000 -c:a pcm_s16le -f aiff tone.aifc # AIFF-C "sowt"
`silence.mp1` has no ffmpeg encoder. It is four hand-built MPEG-1 Layer I frames (mono, 32 kHz,
32 kbit/s, 48 bytes each: header `FF FF 18 C0`, all subband allocations zero), which decode to
silence.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,51 @@
//! Every extension the format table reads decodes with the compiled-in `symphonia` features.
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat, decode, encode, probe};
fn fixture(name: &str) -> Vec<u8> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name);
std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
fn generated(format: FileFormat) -> Vec<u8> {
let tone: Vec<f32> = (0..800).map(|i| (i as f32 * 0.1).sin() * 0.5).collect();
let buf = AudioBuffer { sample_rate: 8000, channels: vec![tone] };
encode(&buf, &EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None }).unwrap()
}
fn bytes_for(ext: &str) -> Vec<u8> {
match ext {
"wav" | "wave" | "bwf" => generated(FileFormat::Wav),
"rf64" => {
let tone = AudioBuffer { sample_rate: 8000, channels: vec![vec![0.25; 800]] };
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
soundcraft_audio_io::encode_wav_rf64(&tone, &opts).unwrap()
}
"aif" | "aiff" => generated(FileFormat::Aiff),
"aifc" => fixture("tone.aifc"),
"flac" => generated(FileFormat::Flac),
"mp3" => fixture("tone.mp3"),
"mp2" => fixture("tone.mp2"),
"mp1" => fixture("silence.mp1"),
"ogg" | "oga" => fixture("tone.ogg"),
"m4a" | "m4b" | "mp4" => fixture("tone.m4a"),
"aac" | "adts" => fixture("tone.aac"),
"caf" => fixture("tone.caf"),
"mkv" => fixture("tone.mkv"),
"webm" => fixture("tone.webm"),
other => panic!("no fixture for the READ extension {other:?}: add one"),
}
}
#[test]
fn every_read_extension_probes_and_decodes() {
for &format in FileFormat::READ {
for ext in format.extensions() {
let bytes = bytes_for(ext);
let info = probe(&bytes, Some(ext)).unwrap_or_else(|e| panic!("probe .{ext}: {e}"));
let (_, buf) = decode(&bytes, Some(ext)).unwrap_or_else(|e| panic!("decode .{ext}: {e}"));
assert!(info.channels >= 1, ".{ext}");
assert!(buf.frames() > 0, ".{ext}: no frames");
}
}
}
+36
View File
@@ -0,0 +1,36 @@
use soundcraft_audio_io::FileFormat;
#[test]
fn every_read_and_write_format_round_trips_through_its_extensions() {
for &format in FileFormat::READ {
assert!(!format.extensions().is_empty(), "{format:?}");
for ext in format.extensions() {
assert_eq!(FileFormat::from_extension(ext), Some(format), "{ext}");
assert_eq!(FileFormat::from_extension(&format!(".{}", ext.to_ascii_uppercase())), Some(format), "{ext}");
assert_eq!(FileFormat::from_path(format!("dir.x/Take 1.{ext}")), Some(format), "{ext}");
}
}
for &format in FileFormat::WRITE {
assert!(!format.write_extensions().is_empty(), "{format:?}");
for ext in format.write_extensions() {
assert_eq!(FileFormat::from_write_extension(ext), Some(format), "{ext}");
assert_eq!(FileFormat::from_write_extension(&ext.to_ascii_uppercase()), Some(format), "{ext}");
assert_eq!(FileFormat::from_extension(ext), Some(format), "{ext}: what is written reads back");
}
assert!(format.write_extensions().contains(&format.default_extension()), "{format:?}");
}
}
#[test]
fn only_names_whose_readers_expect_the_encoder_output_are_writable() {
assert_eq!(FileFormat::writable_extensions(), ["wav", "wave", "bwf", "aif", "aiff", "flac"]);
for read_only in ["rf64", "aifc", "mp3", "ogg", "m4a", "mkv"] {
assert!(FileFormat::from_extension(read_only).is_some(), "{read_only}");
assert_eq!(FileFormat::from_write_extension(read_only), None, "{read_only}");
}
for unknown in ["xyz", "", "mid"] {
assert_eq!(FileFormat::from_extension(unknown), None, "{unknown}");
}
assert_eq!(FileFormat::from_path("no-extension"), None);
assert_eq!(FileFormat::Aiff.default_extension(), "aif");
}
+101
View File
@@ -0,0 +1,101 @@
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileEncoder, FileFormat, SampleFormat, decode, encode};
use std::io::Cursor;
fn encoded(audio: &AudioBuffer, options: &EncodeOptions, streaming: bool) -> Vec<u8> {
if !streaming {
return encode(audio, options).unwrap();
}
let mut writer = FileEncoder::new(Cursor::new(Vec::new()), options, audio.num_channels(), audio.sample_rate, None).unwrap();
// Cross the writer's 4096-frame scratch boundary, then write a short final block.
for start in (0..audio.frames()).step_by(4097) {
let end = (start + 4097).min(audio.frames());
let block: Vec<Vec<f32>> = audio.channels.iter().map(|channel| channel[start..end].to_vec()).collect();
writer.write(&block, end - start).unwrap();
}
writer.finish().unwrap().into_inner()
}
fn assert_float_headroom(streaming: bool) {
let samples = [
-f32::MAX,
-2.0,
-1.25,
-1.0,
-0.5,
-f32::MIN_POSITIVE,
-f32::from_bits(1),
-0.0,
0.0,
f32::from_bits(1),
f32::MIN_POSITIVE,
0.5,
1.0,
1.25,
2.0,
f32::MAX,
];
for format in [FileFormat::Wav, FileFormat::Aiff] {
for dither in [false, true] {
for channels in [1, 2] {
let audio = AudioBuffer {
sample_rate: 48_000,
channels: (0..channels).map(|c| samples.iter().copied().cycle().skip(c).take(4103).collect()).collect(),
};
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither, bwf: None };
let bytes = encoded(&audio, &options, streaming);
let pcm: Vec<u8> =
audio.interleaved().iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
assert!(bytes.ends_with(&pcm), "{format:?}, dither={dither}: stored sample bytes changed");
let (info, decoded) = decode(&bytes, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Float32);
assert_eq!((decoded.sample_rate, decoded.num_channels(), decoded.frames()), (48_000, channels, 4103));
for (channel, (actual, expected)) in decoded.channels.iter().zip(&audio.channels).enumerate() {
for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() {
assert_eq!(actual.to_bits(), expected.to_bits(), "{format:?}, dither={dither}, channel={channel}, frame={frame}");
}
}
}
}
}
}
#[test]
fn whole_file_float32_preserves_finite_headroom() {
assert_float_headroom(false);
}
#[test]
fn streaming_float32_preserves_finite_headroom() {
assert_float_headroom(true);
}
#[test]
fn float32_keeps_non_finite_sample_handling() {
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![f32::NAN, f32::NEG_INFINITY, f32::INFINITY]] };
let expected = [0.0_f32, -1.0, 1.0];
for format in [FileFormat::Wav, FileFormat::Aiff] {
for streaming in [false, true] {
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let bytes = encoded(&audio, &options, streaming);
// Inspect stored samples too: the decoder itself replaces non-finite values.
let pcm: Vec<u8> = expected.iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
assert!(bytes.ends_with(&pcm), "{format:?}, streaming={streaming}");
assert_eq!(decode(&bytes, None).unwrap().1.channels[0], expected);
}
}
}
#[test]
fn integer_pcm_still_clips_at_full_scale() {
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![-2.0, -1.25, -1.0, -0.5, 0.0, 0.5, 1.0, 1.25, 2.0]] };
for (depth, max) in [(BitDepth::Int16, 32767.0 / 32768.0), (BitDepth::Int24, 8_388_607.0 / 8_388_608.0), (BitDepth::Int32, 1.0)] {
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
for streaming in [false, true] {
let options = EncodeOptions { format, bit_depth: depth, dither: false, bwf: None };
let bytes = encoded(&audio, &options, streaming);
let (_, decoded) = decode(&bytes, None).unwrap();
assert_eq!(decoded.channels[0], [-1.0, -1.0, -1.0, -0.5, 0.0, 0.5, max, max, max], "{format:?} {depth:?}, streaming={streaming}");
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Integration tests, built as one test binary: each file is a module.
mod decode_read_list;
mod file_format;
mod float_headroom;
mod malformed;
mod roundtrip;
mod stream;
mod wav_empty_data;
+746
View File
@@ -0,0 +1,746 @@
//! Hostile inputs must produce `Err` (or a best-effort `Ok`), never a panic.
use proptest::prelude::*;
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, probe};
fn small(format: FileFormat, depth: BitDepth, channels: usize) -> Vec<u8> {
let mut b = AudioBuffer::new(44_100, channels, 300);
for ch in &mut b.channels {
for (i, s) in ch.iter_mut().enumerate() {
*s = ((i as f32) * 0.1).sin() * 0.5;
}
}
encode(&b, &EncodeOptions { format, bit_depth: depth, dither: false, bwf: None }).unwrap()
}
/// An ID3v2.4 header whose syncsafe size field declares `size` bytes of tag body.
fn id3_header(size: usize) -> [u8; 10] {
let size = size as u32;
[b'I', b'D', b'3', 4, 0, 0, ((size >> 21) & 0x7F) as u8, ((size >> 14) & 0x7F) as u8, ((size >> 7) & 0x7F) as u8, (size & 0x7F) as u8]
}
/// Wrap `body` in an ID3v2.4 tag.
fn id3_tag(body: &[u8]) -> Vec<u8> {
[&id3_header(body.len())[..], body].concat()
}
/// Little helper for building RIFF files by hand.
fn riff(chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut body = b"WAVE".to_vec();
for (id, data) in chunks {
body.extend_from_slice(*id);
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
body.extend_from_slice(data);
if data.len() % 2 == 1 {
body.push(0);
}
}
let mut out = b"RIFF".to_vec();
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&body);
out
}
fn fmt(tag: u16, channels: u16, sr: u32, bits: u16) -> Vec<u8> {
let align = channels * bits.div_ceil(8);
let mut f = Vec::new();
f.extend_from_slice(&tag.to_le_bytes());
f.extend_from_slice(&channels.to_le_bytes());
f.extend_from_slice(&sr.to_le_bytes());
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
f.extend_from_slice(&align.to_le_bytes());
f.extend_from_slice(&bits.to_le_bytes());
f
}
#[test]
fn every_truncation_is_handled() {
let files = [
small(FileFormat::Wav, BitDepth::Int16, 2),
small(FileFormat::Wav, BitDepth::Float32, 6),
small(FileFormat::Aiff, BitDepth::Int24, 1),
small(FileFormat::Aiff, BitDepth::Float32, 2),
small(FileFormat::Flac, BitDepth::Int16, 2),
small(FileFormat::Flac, BitDepth::Int24, 1),
];
for file in &files {
for n in 0..file.len() {
let cut = &file[..n];
let _ = probe(cut, None);
if let Ok((info, buf)) = decode(cut, None) {
assert_eq!(buf.num_channels(), info.channels as usize);
assert_eq!(buf.frames() as u64, info.frames);
}
}
}
}
#[test]
fn truncated_data_chunk_yields_partial_audio() {
let file = small(FileFormat::Wav, BitDepth::Int16, 2);
let (info, buf) = decode(&file[..file.len() - 400], None).unwrap();
assert_eq!(info.frames, 200);
assert_eq!(buf.frames(), 200);
}
#[test]
fn every_single_byte_corruption_is_handled() {
for file in [small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Aiff, BitDepth::Int16, 2), small(FileFormat::Flac, BitDepth::Int16, 1)]
{
for i in 0..file.len().min(200) {
for v in [0x00, 0xFF, 0x7F, 0x80] {
let mut f = file.clone();
f[i] = v;
let _ = probe(&f, None);
let _ = decode(&f, None);
}
}
}
}
#[test]
fn zero_channels_is_malformed() {
let wav = riff(&[(b"fmt ", fmt(1, 0, 44_100, 16)), (b"data", vec![0; 16])]);
assert!(matches!(decode(&wav, None), Err(AudioError::Malformed(_))));
assert!(probe(&wav, None).is_err());
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
aiff[comm + 8] = 0;
aiff[comm + 9] = 0;
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
}
#[test]
fn zero_sample_rate_is_malformed() {
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
assert!(decode(&wav, None).is_err());
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
for b in &mut aiff[comm + 16..comm + 26] {
*b = 0;
}
assert!(decode(&aiff, None).is_err());
// Infinite exponent.
aiff[comm + 16] = 0x7F;
aiff[comm + 17] = 0xFF;
assert!(decode(&aiff, None).is_err());
}
#[test]
fn absurd_chunk_sizes() {
// fmt chunk claims 4 GiB.
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
wav[16..20].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(decode(&wav, None).is_err());
// data chunk claims 4 GiB: lenient read of what's there.
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
let d = wav.windows(4).position(|w| w == b"data").unwrap();
wav[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
let (info, buf) = decode(&wav, None).unwrap();
assert_eq!((info.frames, buf.frames()), (4, 4));
// An unknown chunk with an absurd size before fmt.
let mut wav = riff(&[(b"junk", vec![0; 4]), (b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
wav[16..20].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes());
assert!(decode(&wav, None).is_err());
// RF64 with a ds64 claiming an exabyte of data.
let mut ds64 = Vec::new();
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
ds64.extend_from_slice(&0u32.to_le_bytes());
let mut rf = riff(&[(b"ds64", ds64), (b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80; 10])]);
rf[..4].copy_from_slice(b"RF64");
let d = rf.windows(4).rposition(|w| w == b"data").unwrap();
rf[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
let (info, buf) = decode(&rf, None).unwrap();
assert_eq!((info.frames, buf.frames()), (10, 10));
// AIFF COMM claims more frames than present; SSND offset beyond the end.
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
aiff[comm + 10..comm + 14].copy_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 300);
let ssnd = aiff.windows(4).position(|w| w == b"SSND").unwrap();
aiff[ssnd + 8..ssnd + 12].copy_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0);
}
fn caf(chunks: &[(&[u8; 4], i64, Vec<u8>)]) -> Vec<u8> {
let mut out = b"caff\0\x01\0\0".to_vec();
for (kind, size, body) in chunks {
out.extend_from_slice(*kind);
out.extend_from_slice(&size.to_be_bytes());
out.extend_from_slice(body);
}
out
}
fn caf_desc(format: &[u8; 4], bytes_per_packet: u32, frames_per_packet: u32, channels: u32, bits: u32) -> (&'static [u8; 4], i64, Vec<u8>) {
let mut d = 48_000f64.to_be_bytes().to_vec();
d.extend_from_slice(format);
for v in [0, bytes_per_packet, frames_per_packet, channels, bits] {
d.extend_from_slice(&v.to_be_bytes());
}
(b"desc", 32, d)
}
fn packet_table(packets: i64, frames: i64, entries: &[u8]) -> Vec<u8> {
[&packets.to_be_bytes()[..], &frames.to_be_bytes(), &[0; 8], entries].concat()
}
#[test]
fn caf_sizes_beyond_the_file_are_errors_not_aborts() {
let aac = || caf_desc(b"aac ", 0, 1024, 2, 0);
let huge = 1i64 << 50;
let cookie = caf(&[aac(), (b"kuki", huge, vec![0; 8])]);
let files = [
cookie.clone(),
[vec![0; 100], cookie].concat(),
caf(&[aac(), (b"data", -1, [&[0; 4][..], b"kuki", &huge.to_be_bytes()].concat())]),
caf(&[aac(), (b"pakt", 24, packet_table(huge, 1024, &[])), (b"data", -1, vec![0; 16])]),
caf(&[aac(), (b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])), (b"data", -1, vec![0; 64])]),
caf(&[aac(), (b"pakt", 25, packet_table(1, 1024, &[0x80]))]),
];
for f in files {
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
let constant = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"pakt", 25, packet_table(1, 1, &[1])), (b"data", -1, vec![0; 16])]);
assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets")));
}
#[test]
fn well_formed_caf_still_reads() {
let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
let chan = [((100u32 << 16) | 1).to_be_bytes(), [0; 4], [0; 4]].concat();
let data = [vec![0; 4], pcm].concat();
let lpcm = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"chan", 12, chan), (b"free", 8, vec![0; 8]), (b"data", data.len() as i64, data)]);
let (info, buf) = decode(&lpcm, None).unwrap();
assert_eq!((info.format, buf.frames()), (FileFormat::Caf, 480));
let table = packet_table(3, 3072, &[10, 0x81, 0x48, 5]);
let aac = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"pakt", table.len() as i64, table), (b"data", 219, vec![0; 219])]);
assert_eq!(probe(&aac, None).unwrap().frames, 3072);
}
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
}
/// A mono 16-bit, 48 kHz audio sample entry of `version`, its version fields and codec atoms.
fn sample_entry(kind: &[u8; 4], version: u8, fields: &[u8], codec: &[u8]) -> Vec<u8> {
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, version, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
atom(kind, &[&base[..], &(48_000u32 << 16).to_be_bytes(), fields, codec].concat())
}
/// An MP4 of `frames` 2-byte samples described by `entry`; `swap` may replace any version-0 atom.
fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec<u8>) -> Vec<u8>) -> Vec<u8> {
let full = |kind: &[u8; 4], fields: &[u32]| {
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
swap(kind, atom(kind, &body))
};
let moov = |offset: u32| {
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], entry].concat());
let stbl =
[stsd, full(b"stts", &[1, frames, 1]), full(b"stsc", &[1, 1, frames, 1]), full(b"stsz", &[2, frames]), full(b"stco", &[1, offset])];
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
let mdia = [full(b"mdhd", &[0, 0, 48_000, frames, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, frames, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
};
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
[ftyp, moov(offset), atom(b"mdat", &pcm)].concat()
}
#[test]
fn mp4_sizes_beyond_the_file_are_errors_not_aborts() {
let pcm = sample_entry(b"sowt", 0, &[], &[]);
let (_, buf) = decode(&mp4(480, &pcm, |_, a| a), None).unwrap();
assert_eq!(buf.frames(), 480);
let huge = |kind: &[u8; 4], a: Vec<u8>| [&1u32.to_be_bytes()[..], kind, &(1u64 << 60).to_be_bytes(), &a[8..]].concat();
// The first field after version and flags: stts's entry count, stsz's sample size.
let first_field_max = |mut a: Vec<u8>| {
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
a
};
let sizes: Vec<u8> = (0..480u32).flat_map(|i| if i == 479 { u32::MAX } else { 2 }.to_be_bytes()).collect();
let per_sample = atom(b"stsz", &[&[0; 8][..], &480u32.to_be_bytes(), &sizes].concat());
// A header that starts inside its container is read whole, past the container's end.
let mdia = atom(b"mdia", &1u32.to_be_bytes());
let straddling = [&b"hdlr"[..], &(1u64 << 60).to_be_bytes(), &[0; 24]].concat();
let files = [
mp4(480, &pcm, |k, a| if k == b"hdlr" { huge(k, a) } else { a }),
mp4(480, &pcm, |k, a| if k == b"stts" { first_field_max(a) } else { a }),
mp4(480, &pcm, |k, a| if k == b"stsz" { first_field_max(a) } else { a }),
mp4(480, &pcm, |k, a| if k == b"stsz" { per_sample.clone() } else { a }),
[atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), atom(b"moov", &atom(b"trak", &mdia)), straddling].concat(),
];
for f in files {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
}
#[test]
fn atoms_running_past_their_parent_are_walked_once() {
// Each moov holds a trak that runs to the end of the file; symphonia stops after the first.
let unit = atom(b"moov", &[&u32::MAX.to_be_bytes()[..], b"trak"].concat());
let f = [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), unit.repeat(1 << 18)].concat();
assert!(decode(&f, None).is_err());
}
#[test]
fn packets_longer_than_any_codec_uses_are_refused() {
let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
let v2 = |frames: u32| [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[1, 0x7f00_0000, 16, 4, 2, frames])].concat();
let lpcm = |frames: u32| sample_entry(b"lpcm", 2, &v2(frames), &[]);
let config = |frames: u32| [&be(&[0, frames])[..], &[0, 16, 40, 10, 14, 1, 0, 255], &be(&[0, 0, 48_000])].concat();
let alac = |frames: u32| sample_entry(b"alac", 0, &[], &atom(b"alac", &config(frames)));
let (_, buf) = decode(&mp4(480, &lpcm(1), |_, a| a), None).unwrap();
assert_eq!(buf.frames(), 480);
for entry in [lpcm(1 << 21), alac(1 << 21)] {
assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets")));
}
}
#[test]
fn flac_metadata_fields_beyond_their_block_are_refused() {
let with_block = |kind: u8, body: &[u8]| {
let f = small(FileFormat::Flac, BitDepth::Int16, 1);
let head = ((0x80 | u32::from(kind)) << 24 | body.len() as u32).to_be_bytes();
[&f[..4], &[f[4] & 0x7f], &f[5..42], &head, body, &f[42..]].concat()
};
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300);
let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat();
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block")));
}
}
#[test]
fn an_id3_tag_before_the_audio_is_skipped_unread() {
let frame = [&b"TIT2"[..], &0x7fff_ffffu32.to_be_bytes(), &[0, 0, 3, b'h', b'i', 0]].concat();
let tag = [&b"ID3\x03\0\0\0\0\0"[..], &[frame.len() as u8], &frame].concat();
let flac = small(FileFormat::Flac, BitDepth::Int16, 1);
assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300);
}
/// Container magic inside an MP3 frame payload must not change detection or make probing reject the stream.
#[test]
fn a_valid_mp3_with_incidental_container_magic_still_probes() {
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo
let mut f: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact.
f[at..at + 8].copy_from_slice(b"caff\0\x01\0\0");
f[at + 8..at + 12].copy_from_slice(b"kuki");
f[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes());
assert_eq!(detect_format(&f, None), FileFormat::Mp3);
let info = probe(&f, None).expect("valid MP3 with incidental container magic");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
}
/// Symphonia 0.6's CAF/FLAC/MP4 `Scoreable` implementations are unconditional
/// (`Supported(255)`), so a container magic before the first audio frame *is* the reader the probe
/// selects; there is no API to ask the probe to skip it. The preflight must therefore keep
/// refusing an oversized CAF cookie instead of ignoring the marker and playing the MPEG frames
/// behind it (which would also let a real malformed container bypass the size guard).
#[test]
fn a_container_marker_before_the_first_audio_frame_is_the_probed_candidate() {
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo
let mp3: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]);
for f in [[vec![0u8], malformed.clone(), mp3.clone()].concat(), [vec![0u8; 32], malformed.clone(), mp3.clone()].concat()] {
// The hint still names the raw stream, but the probe sees the CAF marker first.
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
assert!(matches!(probe(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
// Plain leading junk without format magic is tolerated, as before.
let f = [vec![0u8; 32], mp3].concat();
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
let info = probe(&f, Some("mp3")).expect("junk before a valid MP3");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
}
/// More false `0xFFEx` pairs than the preflight's scoring budget must not make a later container
/// magic inside the valid audio payload authoritative. Past the cap the scan is unchecked and the
/// probe selects the MPEG reader at the first real frame.
#[test]
fn an_exhausted_audio_score_budget_does_not_reject_the_mp3_behind_it() {
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
let mut audio: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact.
audio[at..at + 8].copy_from_slice(b"caff\0\x01\0\0");
audio[at + 8..at + 12].copy_from_slice(b"kuki");
audio[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes());
let false_syncs: Vec<u8> = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect();
let f = [false_syncs, audio].concat();
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
let info = probe(&f, Some("mp3")).expect("valid MP3 after an exhausted audio score budget");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
let (decoded, buf) = decode(&f, Some("mp3")).expect("decode the same MP3");
assert_eq!((decoded.format, decoded.sample_rate, decoded.channels), (FileFormat::Mp3, 44_100, 2));
assert_eq!(decoded.frames, buf.frames() as u64);
}
/// Past the score budget the preflight does not run, so if the probe then selects a container
/// whose reader allocates from sizes in the file, the post-probe refusal still applies.
#[test]
fn a_container_selected_without_its_size_check_is_refused() {
let flac = small(FileFormat::Flac, BitDepth::Int16, 1);
// Exactly the cap of false candidates, then one more that the reader rejects (invalid
// bit-rate), so the preflight stops unchecked before reaching the FLAC marker behind it.
let mut false_syncs: Vec<u8> = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect();
false_syncs.extend_from_slice(&[0xFF, 0xFB, 0xF0, 0x00]);
let f = [false_syncs, flac].concat();
assert_eq!(detect_format(&f, None), FileFormat::Mp3);
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked")));
}
/// A zero-frame FLAC after arbitrary bytes or ID3 tags is still detected and reported as an empty stream, not a
/// decode failure.
#[test]
fn zero_frame_flac_after_a_prefix_is_still_empty() {
let flac =
encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None })
.unwrap();
let tag = id3_tag(b"tag");
for (f, detected) in [
([vec![0x7A; 64], flac.clone()].concat(), FileFormat::Other),
([tag.clone(), vec![0x7A; 16], flac.clone()].concat(), FileFormat::Mp3),
([tag.clone(), tag, flac.clone()].concat(), FileFormat::Mp3),
] {
assert_eq!(detect_format(&f, None), detected);
let info = probe(&f, None).expect("empty FLAC after a prefix");
assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0));
assert_eq!(decode(&f, None).unwrap().1.frames(), 0);
}
}
/// Build one MPEG-4 AAC-LC ADTS frame at 48 kHz stereo around `payload`, with the 13-bit length covering the 7-byte
/// header.
fn adts_frame(payload: &[u8]) -> Vec<u8> {
// MPEG-4 AAC-LC, 48 kHz, stereo; the 13-bit frame length includes this 7-byte header.
let len = 7 + payload.len();
let mut h = [0xFF, 0xF1, 0x4C, 0x00, 0x00, 0x1F, 0xFC];
h[3] = 0x80 | ((len >> 11) & 0x03) as u8; // stereo channel config low bits
h[4] = ((len >> 3) & 0xFF) as u8;
h[5] = (((len & 0x07) as u8) << 5) | 0x1F;
[&h[..], payload].concat()
}
/// False MPEG sync prefixes must not stop a following zero-frame FLAC from probing as empty.
#[test]
fn zero_frame_flac_after_a_false_mpeg_sync_is_still_empty() {
let flac =
encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None })
.unwrap();
for junk in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] {
let f = [junk, flac.clone()].concat();
let info = probe(&f, None).expect("empty FLAC after a false sync");
assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0));
assert_eq!(decode(&f, None).unwrap().1.frames(), 0);
}
}
/// A false MPEG sync prefix must not let malformed CAF or MP4 size fields bypass the container preflight.
#[test]
fn a_false_sync_does_not_bypass_container_checks() {
let bad_caf = caf(&[
caf_desc(b"aac ", 0, 1024, 2, 0),
(b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])),
(b"data", -1, vec![0; 64]),
]);
assert!(matches!(probe(&bad_caf, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
let f = [vec![0xFF, 0xE0, 0x00], bad_caf].concat();
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
let pcm = sample_entry(b"sowt", 0, &[], &[]);
let bad_mp4 = mp4(480, &pcm, |kind, mut a| {
if kind == b"stsz" {
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
}
a
});
assert!(probe(&bad_mp4, None).is_err());
let f = [vec![0xFF, 0xE0, 0x00], bad_mp4].concat();
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
/// Container magic inside a valid ADTS payload must not override AAC detection or make probing fail.
#[test]
fn a_valid_adts_stream_with_incidental_container_magic_still_probes() {
let payload = [0u8; 400];
let mut frames: Vec<Vec<u8>> = (0..8).map(|_| adts_frame(&payload)).collect();
let at = 100; // Inside frame 2's payload, leaving every frame header intact.
frames[2][7 + at..7 + at + 12].copy_from_slice(b"caff\0\x01\0\0kuki");
let f: Vec<u8> = frames.concat();
assert_eq!(detect_format(&f, None), FileFormat::Aac);
let info = probe(&f, None).expect("valid ADTS with incidental container magic");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Aac, 48_000, 2));
}
/// A later valid CAF must not excuse an earlier malformed container that the preflight or probe rejects.
#[test]
fn an_earlier_malformed_container_is_not_excused_by_a_later_valid_one() {
let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]);
let pcm: Vec<u8> = (0..32i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
let data = [vec![0; 4], pcm].concat();
let valid = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"data", data.len() as i64, data)]);
let f = [vec![0u8; 32], malformed, valid].concat();
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
/// Truncated ID3 bodies, declared tag sizes beyond the file, and a complete tag with no audio after it are malformed
/// for both probe and decode.
#[test]
fn truncated_or_oversized_id3_tags_are_malformed() {
let cases = vec![[&id3_header(100)[..], b"xx"].concat(), id3_header(0x0FFF_FFFF).to_vec(), id3_tag(b"tag")];
for f in cases {
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(_))));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(_))));
}
}
#[test]
fn wav_info_entries_beyond_the_file_are_refused() {
let alaw = |len: u32| {
let info = [&b"INFO"[..], b"INAM", &len.to_le_bytes(), b"hi"].concat();
riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])])
};
assert_eq!(decode(&alaw(2), None).unwrap().1.frames(), 800);
let mut f = alaw(0x7fff_fff0);
f[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
let list = f.windows(4).position(|w| w == b"LIST").unwrap();
f[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
#[test]
fn a_wave_behind_another_riff_form_is_checked() {
let info = [&b"INFO"[..], b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
let mut wav = riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]);
wav[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
let list = wav.windows(4).position(|w| w == b"LIST").unwrap();
wav[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
// The probe skips the AVI form and reads the WAVE behind it.
let f = [&b"RIFF"[..], &4u32.to_le_bytes(), b"AVI ", &wav].concat();
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
#[test]
fn wav_info_entries_are_found_where_symphonia_reads_them() {
let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
let alaw = |extra: u16| (b"fmt ", [fmt(6, 1, 8_000, 8), extra.to_le_bytes().to_vec()].concat());
// A-law's extra bytes are read past its chunk; an odd list is padded twice.
let files =
[riff(&[alaw(12), (b"JUNK", [&[0; 4][..], &list].concat())]), [riff(&[alaw(0), (b"LIST", b"adtl\0".to_vec())]), vec![0], list].concat()];
for mut f in files {
f[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
}
#[test]
fn many_tiny_chunks_terminate() {
let mut chunks: Vec<(&[u8; 4], Vec<u8>)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))];
for _ in 0..200_000 {
chunks.push((b"pad ", Vec::new()));
}
let wav = riff(&chunks);
assert!(decode(&wav, None).is_err()); // no data chunk within the scan limit
}
#[test]
fn hand_built_wav_variants() {
// 8-bit unsigned.
let wav = riff(&[(b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80, 0xFF, 0x00])]);
let (info, buf) = decode(&wav, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Int8);
assert_eq!(buf.channels[0], vec![0.0, 127.0 / 128.0, -1.0]);
// 64-bit float, with NaN replaced by silence.
let mut data = Vec::new();
for v in [0.25f64, -0.5, f64::NAN] {
data.extend_from_slice(&v.to_le_bytes());
}
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 64)), (b"data", data)]);
let (info, buf) = decode(&wav, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Float64);
assert_eq!(buf.channels[0], vec![0.25, -0.5, 0.0]);
// Unsupported PCM width.
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 16)), (b"data", vec![0; 4])]);
assert!(matches!(decode(&wav, None), Err(AudioError::Unsupported(_))));
// No data chunk / no fmt chunk.
assert!(decode(&riff(&[(b"fmt ", fmt(1, 1, 8_000, 16))]), None).is_err());
assert!(decode(&riff(&[(b"data", vec![0; 4])]), None).is_err());
// Short fmt chunk.
assert!(decode(&riff(&[(b"fmt ", vec![1, 0, 1]), (b"data", vec![0; 4])]), None).is_err());
// Unknown codec tag goes to symphonia and fails cleanly.
let wav = riff(&[(b"fmt ", fmt(0x1234, 1, 8_000, 16)), (b"data", vec![0; 4])]);
assert!(decode(&wav, None).is_err());
}
#[test]
fn aiff_sowt_little_endian() {
let be = small(FileFormat::Aiff, BitDepth::Int16, 2);
let (_, expect) = decode(&be, None).unwrap();
// Rebuild as AIFC/sowt by hand.
let comm = be.windows(4).position(|w| w == b"COMM").unwrap();
let ssnd = be.windows(4).position(|w| w == b"SSND").unwrap();
let mut comm_body = be[comm + 8..comm + 26].to_vec();
comm_body.extend_from_slice(b"sowt");
comm_body.extend_from_slice(&[0, 0]); // empty pstring, padded
let mut ssnd_body = be[ssnd + 8..].to_vec();
for pair in ssnd_body[8..].as_chunks_mut::<2>().0 {
pair.swap(0, 1);
}
let mut body = b"AIFC".to_vec();
for (id, data) in [(b"COMM", &comm_body), (b"SSND", &ssnd_body)] {
body.extend_from_slice(id);
body.extend_from_slice(&(data.len() as u32).to_be_bytes());
body.extend_from_slice(data);
}
let mut f = b"FORM".to_vec();
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
f.extend_from_slice(&body);
let (info, got) = decode(&f, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Int16);
assert_eq!(got, expect);
}
#[test]
fn detect_format_by_magic_and_hint() {
assert_eq!(detect_format(&small(FileFormat::Wav, BitDepth::Int16, 1), Some("mp3")), FileFormat::Wav);
assert_eq!(detect_format(&small(FileFormat::Aiff, BitDepth::Int16, 1), None), FileFormat::Aiff);
assert_eq!(detect_format(&small(FileFormat::Flac, BitDepth::Int16, 1), None), FileFormat::Flac);
assert_eq!(detect_format(b"OggS\0\x02rest", None), FileFormat::Ogg);
assert_eq!(detect_format(b"caff\0\x01\0\0", None), FileFormat::Caf);
assert_eq!(detect_format(b"ID3\x04\0\0\0\0\0\x00", None), FileFormat::Mp3);
let mut id3_flac = b"ID3\x04\0\0\0\0\0\x02xx".to_vec();
id3_flac.extend_from_slice(b"fLaC");
assert_eq!(detect_format(&id3_flac, None), FileFormat::Flac);
assert_eq!(detect_format(&[0xFF, 0xFB, 0x90, 0x00], None), FileFormat::Mp3);
assert_eq!(detect_format(&[0xFF, 0xF1, 0x50, 0x80], None), FileFormat::Aac);
let mut m4a = b"\0\0\0\x20ftypM4A ".to_vec();
m4a.extend_from_slice(b"....stsd\0\0\0\0\0\0\0\x01\0\0\0\x24alac");
assert_eq!(detect_format(&m4a, None), FileFormat::Alac);
assert_eq!(detect_format(b"\0\0\0\x20ftypM4A ", None), FileFormat::Aac);
assert_eq!(detect_format(b"", Some(".WAV")), FileFormat::Wav);
assert_eq!(detect_format(b"zz", Some("aif")), FileFormat::Aiff);
assert_eq!(detect_format(b"zz", Some("m4a")), FileFormat::Aac);
assert_eq!(detect_format(b"zz", Some("M4B")), FileFormat::Aac);
assert_eq!(detect_format(b"zz", Some("flac")), FileFormat::Flac);
assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other);
assert_eq!(detect_format(b"zz", None), FileFormat::Other);
assert_eq!(detect_format(b"", None), FileFormat::Other);
}
#[test]
fn garbage_in_compressed_containers_errors() {
let garbage: Vec<u8> = (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
for hint in [None, Some("mp3"), Some("ogg"), Some("flac"), Some("m4a"), Some("caf"), Some("aac"), Some("mkv"), Some("wav"), Some("aiff")] {
assert!(decode(&garbage, hint).is_err(), "{hint:?}");
assert!(probe(&garbage, hint).is_err(), "{hint:?}");
}
for magic in [&b"fLaC"[..], b"OggS", b"caff", b"ID3\x03\0\0\0\0\0\0", &[0xFF, 0xFB, 0x90, 0x00], b"\0\0\0\x20ftypM4A "] {
let mut f = magic.to_vec();
f.extend_from_slice(&garbage);
let _ = probe(&f, None);
assert!(decode(&f, None).map(|(_, b)| b.frames()).unwrap_or(0) < 1_000_000);
}
assert!(decode(&[], None).is_err());
}
/// A marker that passes its container check must not let the probe select a different container
/// without its own checks: the reader is refused when the probed format disagrees.
#[test]
fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() {
// The junk byte hides both magics, so the hint picks the marker: the FLAC is checked, but the
// probe reads the WAVE before it.
let bytes = [vec![0], small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Flac, BitDepth::Int24, 2)].concat();
match decode(&bytes, Some("flac")) {
Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"),
Err(other) => panic!("unexpected error kind: {other:?}"),
Ok(_) => panic!("the mismatched container must be refused"),
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
#[test]
fn random_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..2048), hint in 0usize..6) {
let hint = [None, Some("wav"), Some("aiff"), Some("flac"), Some("mp3"), Some("ogg")][hint];
let _ = detect_format(&bytes, hint);
let _ = probe(&bytes, hint);
let _ = decode(&bytes, hint);
}
#[test]
fn random_bodies_behind_valid_headers_never_panic(body in proptest::collection::vec(any::<u8>(), 0..1024), which in 0usize..4) {
let mut f = match which {
0 => b"RIFF\xff\xff\xff\xffWAVE".to_vec(),
1 => b"RF64\xff\xff\xff\xffWAVE".to_vec(),
2 => b"FORM\xff\xff\xff\xffAIFC".to_vec(),
_ => b"FORM\xff\xff\xff\xffAIFF".to_vec(),
};
f.extend_from_slice(&body);
let _ = probe(&f, None);
let _ = decode(&f, None);
}
#[test]
fn random_audio_roundtrips_through_flac(samples in proptest::collection::vec(-1.2f32..1.2, 1..3000), channels in 1usize..4) {
let buf = AudioBuffer::from_interleaved(48_000, channels, &samples);
prop_assume!(buf.frames() > 0);
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int24, dither: false, bwf: None };
let (_, flac) = decode(&encode(&buf, &opts).unwrap(), None).unwrap();
let (_, wav) = decode(&encode(&buf, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap(), None).unwrap();
prop_assert_eq!(flac, wav);
}
}
/// An extensible `fmt ` chunk: cbSize, valid bits, mask, then the 16-byte subtype GUID.
fn extensible_fmt(cb_size: u16, valid_bits: u16, guid: &[u8]) -> Vec<u8> {
let mut f = fmt(0xFFFE, 1, 44_100, 16);
f.extend_from_slice(&cb_size.to_le_bytes());
f.extend_from_slice(&valid_bits.to_le_bytes());
f.extend_from_slice(&4u32.to_le_bytes());
f.extend_from_slice(guid);
f
}
const PCM_GUID: [u8; 16] = [1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xAA, 0, 0x38, 0x9B, 0x71];
#[test]
fn extensible_fmt_is_checked_in_full() {
let data = vec![0u8; 8];
let good = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID)), (b"data", data.clone())]);
assert_eq!(probe(&good, None).unwrap().sample_format, SampleFormat::Int16);
// Subtype GUID cut short after the format code.
let cut = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID[..4])), (b"data", data.clone())]);
assert!(matches!(probe(&cut, None), Err(AudioError::Malformed(_))));
assert!(matches!(decode(&cut, None), Err(AudioError::Malformed(_))));
// cbSize too small for the extension.
let small_cb = riff(&[(b"fmt ", extensible_fmt(0, 16, &PCM_GUID)), (b"data", data.clone())]);
assert!(matches!(probe(&small_cb, None), Err(AudioError::Malformed(_))));
// More valid bits than the 16-bit container holds.
let wide = riff(&[(b"fmt ", extensible_fmt(22, 24, &PCM_GUID)), (b"data", data.clone())]);
assert!(matches!(probe(&wide, None), Err(AudioError::Malformed(_))));
// PCM-like prefix but a foreign GUID tail is not read as native PCM.
let mut foreign = PCM_GUID;
foreign[15] ^= 0xFF;
let foreign = riff(&[(b"fmt ", extensible_fmt(22, 16, &foreign)), (b"data", data)]);
assert!(!matches!(probe(&foreign, None), Ok(i) if i.sample_format == SampleFormat::Int16));
// The Ambisonic PCM subtype is PCM.
let ambi = [1, 0, 0, 0, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0, 0, 0];
let ambi = riff(&[(b"fmt ", extensible_fmt(22, 16, &ambi)), (b"data", vec![0u8; 8])]);
assert_eq!(probe(&ambi, None).unwrap().sample_format, SampleFormat::Int16);
}
@@ -1,5 +1,5 @@
use soundcraft_audio_io::{
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe,
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header,
};
fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer {
@@ -205,7 +205,7 @@ fn encode_rejects_bad_buffers_and_formats() {
for f in [FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Alac, FileFormat::Caf, FileFormat::Other] {
assert!(encode(&sine(48_000, 1, 10), &EncodeOptions { format: f, ..EncodeOptions::default() }).is_err());
}
// Non-finite and out-of-range samples are clamped, not errors.
// Non-finite samples are sanitized; only integer output clips finite samples.
let mut b = AudioBuffer::new(48_000, 1, 4);
b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5];
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
@@ -217,7 +217,7 @@ fn encode_rejects_bad_buffers_and_formats() {
let (_, out) = decode(&bytes, None).unwrap();
assert_eq!(out.channels[0][0], 0.0);
assert!(out.channels[0][1] > 0.99 && out.channels[0][1] <= 1.0);
assert_eq!(out.channels[0][2], -1.0);
assert_eq!(out.channels[0][2], if depth == BitDepth::Float32 { -5.0 } else { -1.0 });
}
}
// Ragged buffers encode the common length.
@@ -268,3 +268,51 @@ fn multichannel_wav_speaker_masks() {
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None);
}
#[test]
fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
let take = sine(48_000, 2, 1_000);
let mut file = wav_float_header(2, 48_000, 0).unwrap();
let len = file.len();
file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes()));
assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap());
assert_eq!(decode(&file, None).unwrap().1.frames(), 600);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap());
assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap());
let big = wav_float_header(2, 48_000, 600_000_000).unwrap();
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
assert!(wav_float_header(0, 48_000, 0).is_err());
}
#[test]
fn zero_frame_flac_round_trips_like_wav_and_aiff() {
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
for depth in [BitDepth::Int16, BitDepth::Int24] {
for channels in [1, 2] {
let opts = EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() };
let bytes = encode(&AudioBuffer::new(48_000, channels, 0), &opts).unwrap();
let info = probe(&bytes, None).unwrap();
assert_eq!((info.format, info.sample_format, info.sample_rate, info.frames), (format, expected_sf(depth), 48_000, 0));
assert_eq!(usize::from(info.channels), channels);
let (info, buf) = decode(&bytes, None).unwrap();
assert_eq!((info.frames, buf.frames(), buf.num_channels()), (0, 0, channels));
}
}
}
}
#[test]
fn flac_claiming_frames_but_holding_none_still_errors() {
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, ..EncodeOptions::default() };
let mut bytes = encode(&AudioBuffer::new(48_000, 2, 0), &opts).unwrap();
bytes[25] = 1; // STREAMINFO total samples (low byte of the 36-bit field) = 1
assert!(decode(&bytes, None).is_err());
}
#[test]
fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
// 4 bytes per frame: the data length fits in u64, but adding the header to it does not.
let frames = u64::MAX / 4;
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
}
+110
View File
@@ -0,0 +1,110 @@
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, BwfInfo, EncodeOptions, FileEncoder, FileFormat, encode, encode_with_channel_mask};
use std::io::{Cursor, Seek, SeekFrom, Write};
fn tone(channels: usize, frames: usize) -> AudioBuffer {
let channels = (0..channels).map(|c| (0..frames).map(|i| ((i * (c + 3)) as f32 * 0.013).sin() * 1.2).collect()).collect();
AudioBuffer { sample_rate: 44_100, channels }
}
fn streamed(buf: &AudioBuffer, opts: &EncodeOptions, mask: Option<u32>) -> Vec<u8> {
let mut out = Cursor::new(b"pre".to_vec());
out.set_position(3);
let mut enc = FileEncoder::new(out, opts, buf.channels.len(), buf.sample_rate, mask).unwrap();
let mut start = 0;
for len in [1, 7, 4095, 4097, 333].into_iter().cycle() {
if start >= buf.frames() {
break;
}
let len = len.min(buf.frames() - start);
let block: Vec<Vec<f32>> = buf.channels.iter().map(|c| c[start..start + len].to_vec()).collect();
enc.write(&block, len).unwrap();
start += len;
}
enc.finish().unwrap().into_inner()
}
#[test]
fn streamed_files_match_whole_buffer_encodes() {
let bwf = BwfInfo { description: "take".into(), time_reference: 123_456, ..Default::default() };
let cases = [
(FileFormat::Wav, BitDepth::Int24, true, None, None),
(FileFormat::Wav, BitDepth::Float32, false, None, None),
(FileFormat::Wav, BitDepth::Int16, true, Some(bwf), Some(0x60F)),
(FileFormat::Aiff, BitDepth::Int24, true, None, None),
(FileFormat::Aiff, BitDepth::Float32, false, None, None),
(FileFormat::Flac, BitDepth::Int16, true, None, None),
(FileFormat::Flac, BitDepth::Int24, false, None, None),
];
for (format, bit_depth, dither, bwf, mask) in cases {
let opts = EncodeOptions { format, bit_depth, dither, bwf };
for channels in [1, 6] {
let buf = tone(channels, 20_001);
let whole = match mask {
Some(m) => encode_with_channel_mask(&buf, &opts, m),
None => encode(&buf, &opts),
}
.unwrap();
assert_eq!(streamed(&buf, &opts, mask), [b"pre".as_slice(), &whole].concat(), "{opts:?}, {channels} channels");
}
}
}
/// A `Write + Seek` sink that accepts `budget` bytes in total and then fails every write, so
/// a `write_all` on it appends a prefix of the block and only then reports the error.
struct FailAfter {
inner: Cursor<Vec<u8>>,
budget: usize,
}
impl Write for FailAfter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.budget == 0 {
return Err(std::io::Error::other("sink is full"));
}
let n = buf.len().min(self.budget);
self.budget -= n;
self.inner.write(&buf[..n])
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
impl Seek for FailAfter {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
#[test]
fn a_failed_write_poisons_the_encoder() {
let buf = tone(2, 20_001);
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
let sink = FailAfter { inner: Cursor::new(Vec::new()), budget: 256 };
let mut enc = FileEncoder::new(sink, &opts, 2, buf.sample_rate, None).unwrap();
// The sink accepts a prefix of the first block, then fails.
assert!(enc.write(&buf.channels, buf.frames()).is_err(), "{format:?}");
// Later calls must not claim success: the bytes on the sink no longer match the
// encoder's counters (and for FLAC the failed frame's samples are already gone).
assert!(matches!(enc.write(&buf.channels, 16), Err(AudioError::Encode(_))), "{format:?}");
assert!(matches!(enc.finish(), Err(AudioError::Encode(_))), "{format:?}");
}
}
#[test]
fn blocks_past_a_format_limit_are_refused_before_anything_is_written() {
let buf = tone(2, 100);
for (format, limit) in [(FileFormat::Aiff, 1u64 << 30), (FileFormat::Flac, 1u64 << 36)] {
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 2, buf.sample_rate, None).unwrap();
enc.write(&buf.channels, 100).unwrap();
assert!(enc.check_room(limit / 2).is_ok(), "{format:?}");
assert!(matches!(enc.check_room(limit), Err(AudioError::TooLarge(_))), "{format:?}");
if let Ok(frames) = usize::try_from(limit) {
assert!(matches!(enc.write(&[], frames), Err(AudioError::TooLarge(_))), "{format:?}");
}
assert_eq!(enc.finish().unwrap().into_inner(), encode(&buf, &opts).unwrap());
}
}
@@ -0,0 +1,87 @@
//! A WAV whose data chunk is empty and followed by metadata decodes to no audio; a recording
//! that was never finalised (RIFF and data sizes left at 0) still yields what was written (#466).
use soundcraft_audio_io::{decode, probe};
fn fmt16(channels: u16, sr: u32) -> Vec<u8> {
let align = channels * 2;
let mut f = Vec::new();
f.extend_from_slice(&1u16.to_le_bytes());
f.extend_from_slice(&channels.to_le_bytes());
f.extend_from_slice(&sr.to_le_bytes());
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
f.extend_from_slice(&align.to_le_bytes());
f.extend_from_slice(&16u16.to_le_bytes());
f
}
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut c = id.to_vec();
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
c.extend_from_slice(data);
if data.len() % 2 == 1 {
c.push(0);
}
c
}
/// A finished file: correct RIFF size, chunks as given.
fn riff(chunks: &[Vec<u8>]) -> Vec<u8> {
let mut body = b"WAVE".to_vec();
for c in chunks {
body.extend_from_slice(c);
}
let mut out = b"RIFF".to_vec();
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&body);
out
}
fn list_info() -> Vec<u8> {
let mut l = b"INFO".to_vec();
l.extend_from_slice(&chunk(b"INAM", b"Silence"));
chunk(b"LIST", &l)
}
#[test]
fn an_empty_data_chunk_followed_by_metadata_has_no_frames() {
let file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[]), list_info(), chunk(b"bext", &[0u8; 602])]);
let info = probe(&file, Some("wav")).unwrap();
assert_eq!(info.frames, 0, "the LIST and bext chunks are not samples");
let (info, buf) = decode(&file, Some("wav")).unwrap();
assert_eq!((info.frames, buf.frames()), (0, 0));
// The same with the metadata first.
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), list_info(), chunk(b"data", &[])]);
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
// And with a single trailing chunk of odd size and no pad byte at the very end.
let mut file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &[]), chunk(b"note", b"abc")]);
file.pop();
let riff_size = (file.len() - 8) as u32;
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
}
#[test]
fn a_recording_that_was_never_finalised_still_gives_its_audio() {
let samples: Vec<u8> = (0..400i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
// RIFF size and data size both left at 0, as a recorder writes them until it finishes.
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
file.extend_from_slice(&samples);
file[4..8].copy_from_slice(&0u32.to_le_bytes());
let (info, buf) = decode(&file, Some("wav")).unwrap();
assert_eq!(info.frames, 200);
assert_eq!(buf.frames(), 200);
// Even if the RIFF size is the file's, audio that is not a run of chunks is still audio.
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
file.extend_from_slice(&samples);
let riff_size = (file.len() - 8) as u32;
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 200);
}
#[test]
fn a_data_chunk_with_audio_is_unchanged() {
let samples: Vec<u8> = (0..100i16).flat_map(|i| i.to_le_bytes()).collect();
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &samples), list_info()]);
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 100);
}
-298
View File
@@ -1,298 +0,0 @@
//! Hostile inputs must produce `Err` (or a best-effort `Ok`), never a panic.
use proptest::prelude::*;
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, probe};
fn small(format: FileFormat, depth: BitDepth, channels: usize) -> Vec<u8> {
let mut b = AudioBuffer::new(44_100, channels, 300);
for ch in &mut b.channels {
for (i, s) in ch.iter_mut().enumerate() {
*s = ((i as f32) * 0.1).sin() * 0.5;
}
}
encode(&b, &EncodeOptions { format, bit_depth: depth, dither: false, bwf: None }).unwrap()
}
/// Little helper for building RIFF files by hand.
fn riff(chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut body = b"WAVE".to_vec();
for (id, data) in chunks {
body.extend_from_slice(*id);
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
body.extend_from_slice(data);
if data.len() % 2 == 1 {
body.push(0);
}
}
let mut out = b"RIFF".to_vec();
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&body);
out
}
fn fmt(tag: u16, channels: u16, sr: u32, bits: u16) -> Vec<u8> {
let align = channels * bits.div_ceil(8);
let mut f = Vec::new();
f.extend_from_slice(&tag.to_le_bytes());
f.extend_from_slice(&channels.to_le_bytes());
f.extend_from_slice(&sr.to_le_bytes());
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
f.extend_from_slice(&align.to_le_bytes());
f.extend_from_slice(&bits.to_le_bytes());
f
}
#[test]
fn every_truncation_is_handled() {
let files = [
small(FileFormat::Wav, BitDepth::Int16, 2),
small(FileFormat::Wav, BitDepth::Float32, 6),
small(FileFormat::Aiff, BitDepth::Int24, 1),
small(FileFormat::Aiff, BitDepth::Float32, 2),
small(FileFormat::Flac, BitDepth::Int16, 2),
small(FileFormat::Flac, BitDepth::Int24, 1),
];
for file in &files {
for n in 0..file.len() {
let cut = &file[..n];
let _ = probe(cut, None);
if let Ok((info, buf)) = decode(cut, None) {
assert_eq!(buf.num_channels(), info.channels as usize);
assert_eq!(buf.frames() as u64, info.frames);
}
}
}
}
#[test]
fn truncated_data_chunk_yields_partial_audio() {
let file = small(FileFormat::Wav, BitDepth::Int16, 2);
let (info, buf) = decode(&file[..file.len() - 400], None).unwrap();
assert_eq!(info.frames, 200);
assert_eq!(buf.frames(), 200);
}
#[test]
fn every_single_byte_corruption_is_handled() {
for file in [small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Aiff, BitDepth::Int16, 2), small(FileFormat::Flac, BitDepth::Int16, 1)]
{
for i in 0..file.len().min(200) {
for v in [0x00, 0xFF, 0x7F, 0x80] {
let mut f = file.clone();
f[i] = v;
let _ = probe(&f, None);
let _ = decode(&f, None);
}
}
}
}
#[test]
fn zero_channels_is_malformed() {
let wav = riff(&[(b"fmt ", fmt(1, 0, 44_100, 16)), (b"data", vec![0; 16])]);
assert!(matches!(decode(&wav, None), Err(AudioError::Malformed(_))));
assert!(probe(&wav, None).is_err());
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
aiff[comm + 8] = 0;
aiff[comm + 9] = 0;
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
}
#[test]
fn zero_sample_rate_is_malformed() {
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
assert!(decode(&wav, None).is_err());
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
for b in &mut aiff[comm + 16..comm + 26] {
*b = 0;
}
assert!(decode(&aiff, None).is_err());
// Infinite exponent.
aiff[comm + 16] = 0x7F;
aiff[comm + 17] = 0xFF;
assert!(decode(&aiff, None).is_err());
}
#[test]
fn absurd_chunk_sizes() {
// fmt chunk claims 4 GiB.
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
wav[16..20].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(decode(&wav, None).is_err());
// data chunk claims 4 GiB: lenient read of what's there.
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
let d = wav.windows(4).position(|w| w == b"data").unwrap();
wav[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
let (info, buf) = decode(&wav, None).unwrap();
assert_eq!((info.frames, buf.frames()), (4, 4));
// An unknown chunk with an absurd size before fmt.
let mut wav = riff(&[(b"junk", vec![0; 4]), (b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
wav[16..20].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes());
assert!(decode(&wav, None).is_err());
// RF64 with a ds64 claiming an exabyte of data.
let mut ds64 = Vec::new();
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
ds64.extend_from_slice(&0u32.to_le_bytes());
let mut rf = riff(&[(b"ds64", ds64), (b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80; 10])]);
rf[..4].copy_from_slice(b"RF64");
let d = rf.windows(4).rposition(|w| w == b"data").unwrap();
rf[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
let (info, buf) = decode(&rf, None).unwrap();
assert_eq!((info.frames, buf.frames()), (10, 10));
// AIFF COMM claims more frames than present; SSND offset beyond the end.
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
aiff[comm + 10..comm + 14].copy_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 300);
let ssnd = aiff.windows(4).position(|w| w == b"SSND").unwrap();
aiff[ssnd + 8..ssnd + 12].copy_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0);
}
#[test]
fn many_tiny_chunks_terminate() {
let mut chunks: Vec<(&[u8; 4], Vec<u8>)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))];
for _ in 0..200_000 {
chunks.push((b"pad ", Vec::new()));
}
let wav = riff(&chunks);
assert!(decode(&wav, None).is_err()); // no data chunk within the scan limit
}
#[test]
fn hand_built_wav_variants() {
// 8-bit unsigned.
let wav = riff(&[(b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80, 0xFF, 0x00])]);
let (info, buf) = decode(&wav, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Int8);
assert_eq!(buf.channels[0], vec![0.0, 127.0 / 128.0, -1.0]);
// 64-bit float, with NaN replaced by silence.
let mut data = Vec::new();
for v in [0.25f64, -0.5, f64::NAN] {
data.extend_from_slice(&v.to_le_bytes());
}
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 64)), (b"data", data)]);
let (info, buf) = decode(&wav, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Float64);
assert_eq!(buf.channels[0], vec![0.25, -0.5, 0.0]);
// Unsupported PCM width.
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 16)), (b"data", vec![0; 4])]);
assert!(matches!(decode(&wav, None), Err(AudioError::Unsupported(_))));
// No data chunk / no fmt chunk.
assert!(decode(&riff(&[(b"fmt ", fmt(1, 1, 8_000, 16))]), None).is_err());
assert!(decode(&riff(&[(b"data", vec![0; 4])]), None).is_err());
// Short fmt chunk.
assert!(decode(&riff(&[(b"fmt ", vec![1, 0, 1]), (b"data", vec![0; 4])]), None).is_err());
// Unknown codec tag goes to symphonia and fails cleanly.
let wav = riff(&[(b"fmt ", fmt(0x1234, 1, 8_000, 16)), (b"data", vec![0; 4])]);
assert!(decode(&wav, None).is_err());
}
#[test]
fn aiff_sowt_little_endian() {
let be = small(FileFormat::Aiff, BitDepth::Int16, 2);
let (_, expect) = decode(&be, None).unwrap();
// Rebuild as AIFC/sowt by hand.
let comm = be.windows(4).position(|w| w == b"COMM").unwrap();
let ssnd = be.windows(4).position(|w| w == b"SSND").unwrap();
let mut comm_body = be[comm + 8..comm + 26].to_vec();
comm_body.extend_from_slice(b"sowt");
comm_body.extend_from_slice(&[0, 0]); // empty pstring, padded
let mut ssnd_body = be[ssnd + 8..].to_vec();
for pair in ssnd_body[8..].as_chunks_mut::<2>().0 {
pair.swap(0, 1);
}
let mut body = b"AIFC".to_vec();
for (id, data) in [(b"COMM", &comm_body), (b"SSND", &ssnd_body)] {
body.extend_from_slice(id);
body.extend_from_slice(&(data.len() as u32).to_be_bytes());
body.extend_from_slice(data);
}
let mut f = b"FORM".to_vec();
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
f.extend_from_slice(&body);
let (info, got) = decode(&f, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Int16);
assert_eq!(got, expect);
}
#[test]
fn detect_format_by_magic_and_hint() {
assert_eq!(detect_format(&small(FileFormat::Wav, BitDepth::Int16, 1), Some("mp3")), FileFormat::Wav);
assert_eq!(detect_format(&small(FileFormat::Aiff, BitDepth::Int16, 1), None), FileFormat::Aiff);
assert_eq!(detect_format(&small(FileFormat::Flac, BitDepth::Int16, 1), None), FileFormat::Flac);
assert_eq!(detect_format(b"OggS\0\x02rest", None), FileFormat::Ogg);
assert_eq!(detect_format(b"caff\0\x01\0\0", None), FileFormat::Caf);
assert_eq!(detect_format(b"ID3\x04\0\0\0\0\0\x00", None), FileFormat::Mp3);
let mut id3_flac = b"ID3\x04\0\0\0\0\0\x02xx".to_vec();
id3_flac.extend_from_slice(b"fLaC");
assert_eq!(detect_format(&id3_flac, None), FileFormat::Flac);
assert_eq!(detect_format(&[0xFF, 0xFB, 0x90, 0x00], None), FileFormat::Mp3);
assert_eq!(detect_format(&[0xFF, 0xF1, 0x50, 0x80], None), FileFormat::Aac);
let mut m4a = b"\0\0\0\x20ftypM4A ".to_vec();
m4a.extend_from_slice(b"....stsd\0\0\0\0\0\0\0\x01\0\0\0\x24alac");
assert_eq!(detect_format(&m4a, None), FileFormat::Alac);
assert_eq!(detect_format(b"\0\0\0\x20ftypM4A ", None), FileFormat::Aac);
assert_eq!(detect_format(b"", Some(".WAV")), FileFormat::Wav);
assert_eq!(detect_format(b"zz", Some("aif")), FileFormat::Aiff);
assert_eq!(detect_format(b"zz", Some("m4a")), FileFormat::Aac);
assert_eq!(detect_format(b"zz", Some("flac")), FileFormat::Flac);
assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other);
assert_eq!(detect_format(b"zz", None), FileFormat::Other);
assert_eq!(detect_format(b"", None), FileFormat::Other);
}
#[test]
fn garbage_in_compressed_containers_errors() {
let garbage: Vec<u8> = (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
for hint in [None, Some("mp3"), Some("ogg"), Some("flac"), Some("m4a"), Some("caf"), Some("aac"), Some("mkv"), Some("wav"), Some("aiff")] {
assert!(decode(&garbage, hint).is_err(), "{hint:?}");
assert!(probe(&garbage, hint).is_err(), "{hint:?}");
}
for magic in [&b"fLaC"[..], b"OggS", b"caff", b"ID3\x03\0\0\0\0\0\0", &[0xFF, 0xFB, 0x90, 0x00], b"\0\0\0\x20ftypM4A "] {
let mut f = magic.to_vec();
f.extend_from_slice(&garbage);
let _ = probe(&f, None);
assert!(decode(&f, None).map(|(_, b)| b.frames()).unwrap_or(0) < 1_000_000);
}
assert!(decode(&[], None).is_err());
}
proptest! {
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
#[test]
fn random_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..2048), hint in 0usize..6) {
let hint = [None, Some("wav"), Some("aiff"), Some("flac"), Some("mp3"), Some("ogg")][hint];
let _ = detect_format(&bytes, hint);
let _ = probe(&bytes, hint);
let _ = decode(&bytes, hint);
}
#[test]
fn random_bodies_behind_valid_headers_never_panic(body in proptest::collection::vec(any::<u8>(), 0..1024), which in 0usize..4) {
let mut f = match which {
0 => b"RIFF\xff\xff\xff\xffWAVE".to_vec(),
1 => b"RF64\xff\xff\xff\xffWAVE".to_vec(),
2 => b"FORM\xff\xff\xff\xffAIFC".to_vec(),
_ => b"FORM\xff\xff\xff\xffAIFF".to_vec(),
};
f.extend_from_slice(&body);
let _ = probe(&f, None);
let _ = decode(&f, None);
}
#[test]
fn random_audio_roundtrips_through_flac(samples in proptest::collection::vec(-1.2f32..1.2, 1..3000), channels in 1usize..4) {
let buf = AudioBuffer::from_interleaved(48_000, channels, &samples);
prop_assume!(buf.frames() > 0);
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int24, dither: false, bwf: None };
let (_, flac) = decode(&encode(&buf, &opts).unwrap(), None).unwrap();
let (_, wav) = decode(&encode(&buf, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap(), None).unwrap();
prop_assert_eq!(flac, wav);
}
}
+6
View File
@@ -9,9 +9,15 @@ repository.workspace = true
[dependencies]
soundcraft-engine.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-time.workspace = true
serde_json.workspace = true
log.workspace = true
thiserror.workspace = true
# The launch flags (`args`): the web build has none.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
clap.workspace = true
[lints]
workspace = true
+199
View File
@@ -0,0 +1,199 @@
//! The control and automation launch options, as clap types, so each flag and its environment
//! variable are declared once for `soundcraft-cli` and the desktop app. A flag wins over its
//! variable.
//!
//! Parse with [`blank_env_is_unset`] applied to the arguments, so an empty variable counts as unset,
//! and pass a parse error through [`name_env_vars`], so a bad value names the variable it came from.
//! [`conflict`] reports two arguments that can't go together, each named as it was given, for a
//! conflict that depends on the mode, which clap's `conflicts_with` can't express.
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::OnceLock;
use clap::error::{ContextKind, ContextValue, ErrorKind};
use clap::parser::ValueSource;
use clap::{Arg, ArgAction, ArgMatches, Args, Command};
use crate::ControlTarget;
/// The running app a client calls (`soundcraft-cli app`).
#[derive(Args)]
pub struct ControlClientArgs {
/// The app's control port or host:port
#[arg(long, value_name = "P", env = "SOUNDCRAFT_CONTROL_PORT", default_value = "7979", value_parser = control_target)]
pub port: ControlTarget,
}
/// The control server the desktop app starts. The same variable as [`ControlClientArgs`], so one
/// export connects the two.
#[derive(Args)]
pub struct ControlServerArgs {
/// Start the localhost JSON-lines control server on this port (0 picks a free port and prints
/// it)
// allow_negative_numbers makes `--control -1` a bad port rather than an unexpected argument.
#[arg(long, value_name = "PORT", env = "SOUNDCRAFT_CONTROL_PORT", value_parser = control_target, allow_negative_numbers = true)]
pub control: Option<ControlTarget>,
}
/// The folders automation may touch. Once any root is given, files are confined to the roots, and
/// a kind left out grants none of its access. No variable stands in for them: one left exported
/// must not change what an agent may read or write.
#[derive(Args)]
pub struct AutomationArgs {
/// A folder the agent's commands may read from; repeat for several
#[arg(long, value_name = "DIR", action = ArgAction::Append)]
pub automation_read_root: Vec<PathBuf>,
/// A folder the agent's commands may write to; repeat for several
#[arg(long, value_name = "DIR", action = ArgAction::Append)]
pub automation_write_root: Vec<PathBuf>,
}
/// A [`ControlTarget`], for clap's `value_parser`: `soundcraft-cli app --port`, `mcp --connect`,
/// `soundcraft --control`, and SOUNDCRAFT_CONTROL_PORT, which may carry spaces.
pub fn control_target(s: &str) -> Result<ControlTarget, NotATarget> {
let (host, port) = host_and_port(s.trim())?;
Ok(ControlTarget { host: host.map(str::to_owned), port })
}
/// `PORT` or `HOST:PORT`, split. An IPv6 host goes in brackets (`[::1]:7979`), as a socket address
/// is written: in `::1:7979` the host and port can't be told apart.
fn host_and_port(s: &str) -> Result<(Option<&str>, u16), NotATarget> {
let (host, port) = match s.rsplit_once(':') {
Some(("", _)) => return Err(NotATarget),
Some((host, _)) if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) => return Err(NotATarget),
Some((host, port)) => (Some(host), port),
None => (None, s),
};
Ok((host, port.parse().map_err(|_| NotATarget)?))
}
/// A [`control_target`] value that is neither a port nor `host:port`.
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
#[error("expected a port, 0-65535, or host:port")]
pub struct NotATarget;
/// `0.1.0 (linux x86_64)`: the workspace version and the platform the binary was built for. Every
/// crate takes its version from the workspace, so the value is the same in both binaries. A
/// `const` can't build it: `concat!` accepts only literals, not `std::env::consts`.
pub fn long_version() -> &'static str {
static LONG: OnceLock<String> = OnceLock::new();
LONG.get_or_init(|| format!("{} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH))
}
/// For `Command::mut_args`: an empty or blank variable counts as unset, so `SOUNDCRAFT_CONTROL_PORT=`
/// turns a fallback off without `unset`. clap would pass the empty value to the parser.
pub fn blank_env_is_unset(arg: Arg) -> Arg {
let blank = arg.get_env().and_then(std::env::var_os).is_some_and(|v| v.to_str().is_some_and(|v| v.trim().is_empty()));
if blank { arg.env(None::<&str>) } else { arg }
}
/// clap names the flag when a value from its variable is bad or conflicts with another, as if the
/// flag had been given: name the variable instead when the flag isn't among `args` (without the
/// binary name; a first one naming a subcommand selects whose arguments to look in).
pub fn name_env_vars(mut error: clap::Error, cmd: &Command, args: &[OsString]) -> clap::Error {
// `--flag value` or `--flag=value`.
let given =
|long: &str| args.iter().filter_map(|a| a.to_str()?.strip_prefix("--")?.strip_prefix(long)).any(|r| r.is_empty() || r.starts_with('='));
let sub = args.first().and_then(|name| cmd.find_subcommand(name)).unwrap_or(cmd);
for kind in [ContextKind::InvalidArg, ContextKind::PriorArg] {
let Some(ContextValue::String(shown)) = error.get(kind) else { continue };
let Some(arg) = sub.get_arguments().find(|a| a.to_string() == *shown) else { continue };
let (Some(long), Some(var)) = (arg.get_long(), arg.get_env()) else { continue };
if !given(long) {
error.insert(kind, ContextValue::String(var.to_string_lossy().into_owned()));
}
}
error
}
/// clap's own conflict error when `m`, parsed by `cmd`, has both `a` and `b`, each named as it was
/// given: the flag, or its variable.
pub fn conflict(cmd: &mut Command, m: &ArgMatches, a: &str, b: &str) -> Result<(), clap::Error> {
if m.value_source(a).is_none() || m.value_source(b).is_none() {
return Ok(());
}
let shown = |id: &str| {
let arg = cmd.get_arguments().find(|arg| arg.get_id() == id);
match (m.value_source(id), arg.and_then(Arg::get_env)) {
(Some(ValueSource::EnvVariable), Some(var)) => var.to_string_lossy().into_owned(),
_ => arg.map_or_else(|| id.to_owned(), |arg| arg.to_string()),
}
};
let (shown_a, shown_b) = (shown(a), shown(b));
let mut error = clap::Error::new(ErrorKind::ArgumentConflict).with_cmd(cmd);
error.insert(ContextKind::InvalidArg, ContextValue::String(shown_a));
error.insert(ContextKind::PriorArg, ContextValue::String(shown_b));
error.insert(ContextKind::Usage, ContextValue::StyledStr(cmd.render_usage()));
Err(error)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_target_is_a_port_or_host_and_port() {
let target = |host: Option<&str>, port| Ok(ControlTarget { host: host.map(str::to_owned), port });
assert_eq!(control_target("7979"), target(None, 7979));
assert_eq!(control_target(" 0 "), target(None, 0));
assert_eq!(control_target("localhost:7979 "), target(Some("localhost"), 7979));
assert_eq!(control_target("10.0.0.5:1"), target(Some("10.0.0.5"), 1));
assert_eq!(control_target("[::1]:7979"), target(Some("[::1]"), 7979));
for bad in ["", "x", "78787", ":7979", "host:", "host:x", "-1", "::1:7979", "[::1:7979"] {
assert_eq!(control_target(bad), Err(NotATarget), "{bad:?}");
}
}
/// The control server listens on 127.0.0.1 only: `[::1]` is another address.
#[test]
fn a_loopback_target_is_a_port_127_0_0_1_or_localhost() {
for loopback in ["50494", "127.0.0.1:7979", "localhost:7979"] {
assert!(control_target(loopback).is_ok_and(|t| t.is_loopback()), "{loopback:?}");
}
for remote in ["10.0.0.5:7979", "example.com:7979", "[::1]:7979"] {
assert!(control_target(remote).is_ok_and(|t| !t.is_loopback()), "{remote:?}");
}
}
#[test]
fn a_control_target_displays_as_a_socket_address() {
for (given, shown) in [("7979", "127.0.0.1:7979"), ("localhost:1", "localhost:1"), ("[::1]:7979", "[::1]:7979")] {
assert_eq!(control_target(given).map(|t| t.to_string()), Ok(shown.to_owned()), "{given:?}");
}
}
/// A command with one port flag backed by a variable no test sets.
fn port_command() -> Command {
Command::new("t").arg(Arg::new("port").long("port").env("SOUNDCRAFT_TEST_UNSET_PORT").value_parser(control_target))
}
#[test]
fn name_env_vars_names_the_variable_only_when_the_flag_was_not_given() {
let mut cmd = port_command();
let e = cmd.try_get_matches_from_mut(["t", "--port", "x"]).unwrap_err();
let flag = name_env_vars(e, &cmd, &["--port".into(), "x".into()]);
assert_eq!(flag.get(ContextKind::InvalidArg), Some(&ContextValue::String("--port <port>".into())));
let e = cmd.try_get_matches_from_mut(["t", "--port=x"]).unwrap_err();
// As the error from the variable would read: the flag is not among the arguments.
let var = name_env_vars(e, &cmd, &[]);
assert_eq!(var.get(ContextKind::InvalidArg), Some(&ContextValue::String("SOUNDCRAFT_TEST_UNSET_PORT".into())));
}
#[test]
fn blank_env_is_unset_keeps_an_unset_variable() {
let arg = blank_env_is_unset(Arg::new("port").env("SOUNDCRAFT_TEST_UNSET_PORT"));
assert_eq!(arg.get_env(), Some(std::ffi::OsStr::new("SOUNDCRAFT_TEST_UNSET_PORT")));
}
#[test]
fn conflict_is_a_clap_usage_error_naming_both_flags() {
let mut cmd = port_command().arg(Arg::new("connect").long("connect"));
let m = cmd.try_get_matches_from_mut(["t", "--port", "1", "--connect", "2"]).unwrap();
let e = conflict(&mut cmd, &m, "port", "connect").unwrap_err();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::ArgumentConflict, 2));
assert!(e.to_string().starts_with("error: the argument '--port <port>' cannot be used with '--connect <connect>'\n\nUsage: t"), "{e}");
let m = cmd.try_get_matches_from_mut(["t", "--port", "1"]).unwrap();
assert!(conflict(&mut cmd, &m, "port", "connect").is_ok());
}
}
+205 -40
View File
@@ -5,14 +5,21 @@
//! running app's `--control` port.
//! - [`mcp::Server`]: a Model Context Protocol server over stdio (newline-delimited JSON-RPC 2.0)
//! exposing SoundCraft as tools and resources.
//! - [`args`]: the control and automation launch flags and their environment variables, as clap
//! types.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(not(target_arch = "wasm32"))]
pub mod args;
pub mod mcp;
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use soundcraft_engine::file_access::{self, AutomationRoots};
use std::fmt;
use std::io::{self, BufRead, BufReader, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::time::Duration;
/// Answers control-protocol methods.
@@ -26,16 +33,22 @@ pub trait Backend {
/// An in-process engine (no UI).
pub struct Headless {
pub engine: Engine,
roots: Option<Arc<AutomationRoots>>,
}
impl Headless {
pub fn new(engine: Engine) -> Self {
Headless { engine }
Headless { engine, roots: None }
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
/// Confine every file the session's commands read and write to `roots`
/// (`soundcraft-cli mcp --automation-read-root … --automation-write-root …`).
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
self.roots = roots;
self
}
fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> {
match method {
"engine.execute" | "command" => {
let id = params.get("command").or_else(|| params.get("id")).and_then(Value::as_str).ok_or("`command` required")?;
@@ -56,6 +69,13 @@ impl Backend for Headless {
other => Err(format!("unknown method `{other}`")),
}
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.answer(method, params))
}
fn has_ui(&self) -> bool {
false
}
@@ -64,65 +84,161 @@ impl Backend for Headless {
}
}
/// Where a running app's control channel listens: what `soundcraft-cli app --port`,
/// `soundcraft-cli mcp --connect`, `soundcraft --control` and SOUNDCRAFT_CONTROL_PORT give, parsed
/// once (`args::control_target`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ControlTarget {
/// As given: a name, an IPv4 address, or an IPv6 address in brackets (`[::1]`). `None`:
/// 127.0.0.1.
pub host: Option<String>,
pub port: u16,
}
impl ControlTarget {
/// Can the desktop app's control server, which listens on 127.0.0.1 only, serve this target?
/// True for no host, `127.0.0.1` and `localhost`; `[::1]` is another address.
pub fn is_loopback(&self) -> bool {
self.host.as_deref().is_none_or(|host| ["127.0.0.1", "localhost"].contains(&host))
}
}
/// `host:port`, as a socket address is written.
impl fmt::Display for ControlTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.host.as_deref().unwrap_or("127.0.0.1"), self.port)
}
}
/// Why a [`Remote`] request failed.
#[derive(Debug, thiserror::Error)]
pub enum RemoteError {
/// The host name didn't resolve.
#[error("{addr}: {source}")]
Resolve { addr: String, source: io::Error },
#[error("cannot reach SoundCraft at {addr}: no addresses found")]
NoAddress { addr: String },
#[error("cannot reach SoundCraft at {addr}: {source}")]
Unreachable { addr: String, source: io::Error },
/// Reading or writing the connection failed.
#[error(transparent)]
Io(#[from] io::Error),
/// The app closed the connection before it replied.
#[error("connection closed")]
Closed,
/// The reply isn't JSON.
#[error(transparent)]
Reply(#[from] serde_json::Error),
/// The app's reply to a request it refused.
#[error("{0}")]
App(String),
/// The connection was lost after a request that changes state went out: it isn't repeated,
/// since the app may already have executed it.
#[error("{source}; `{method}` may or may not have been executed, check the session before repeating it")]
Unconfirmed { method: String, source: Box<RemoteError> },
}
impl RemoteError {
/// The connection went away, so a fresh one may succeed.
fn is_lost_connection(&self) -> bool {
match self {
RemoteError::Closed => true,
RemoteError::Io(e) => matches!(e.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::ConnectionReset | io::ErrorKind::ConnectionAborted),
_ => false,
}
}
}
type Connection = (TcpStream, BufReader<TcpStream>);
/// A TCP client for a running app's control channel.
pub struct Remote {
/// The target as `host:port`, for connecting and for messages.
addr: String,
conn: Option<(TcpStream, BufReader<TcpStream>)>,
conn: Option<Connection>,
next_id: u64,
}
impl Remote {
/// `target` is a port (`7979`) or `host:port`.
pub fn new(target: &str) -> Self {
let addr = if target.contains(':') { target.to_string() } else { format!("127.0.0.1:{target}") };
Remote { addr, conn: None, next_id: 1 }
/// A client for the app at `target`; it connects on the first request.
pub fn new(target: &ControlTarget) -> Self {
Remote { addr: target.to_string(), conn: None, next_id: 1 }
}
fn connect(&mut self) -> Result<(), String> {
if self.conn.is_some() {
return Ok(());
/// The open connection, connecting first when there is none.
fn connection(&mut self) -> Result<&mut Connection, RemoteError> {
match self.conn {
Some(ref mut conn) => Ok(conn),
None => Ok(self.conn.insert(connect(&self.addr)?)),
}
let sock = self.addr.parse().map_err(|e| format!("{}: {e}", self.addr))?;
let s =
TcpStream::connect_timeout(&sock, Duration::from_millis(800)).map_err(|e| format!("cannot reach SoundCraft at {}: {e}", self.addr))?;
s.set_read_timeout(Some(Duration::from_secs(120))).map_err(|e| e.to_string())?;
let r = s.try_clone().map_err(|e| e.to_string())?;
self.conn = Some((s, BufReader::new(r)));
Ok(())
}
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, String> {
self.connect()?;
/// One request/reply exchange. On failure the flag says whether the request line was already
/// written to the socket (so the app may have executed it).
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (RemoteError, bool)> {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
let (w, r) = self.conn.as_mut().ok_or("not connected")?;
writeln!(w, "{line}").map_err(|e| e.to_string())?;
let (w, r) = self.connection().map_err(|e| (e, false))?;
writeln!(w, "{line}").map_err(|e| (e.into(), false))?;
let mut buf = String::new();
r.read_line(&mut buf).map_err(|e| e.to_string())?;
r.read_line(&mut buf).map_err(|e| (e.into(), true))?;
if buf.is_empty() {
return Err("connection closed".into());
return Err((RemoteError::Closed, true));
}
let v: Value = serde_json::from_str(&buf).map_err(|e| e.to_string())?;
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.into(), true))?;
if v.get("ok").and_then(Value::as_bool).unwrap_or(false) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
Err((RemoteError::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()), true))
}
}
/// Calls `method`. A request lost with its connection is sent once more on a fresh one when
/// it only reads state.
pub fn request(&mut self, method: &str, params: Value) -> Result<Value, RemoteError> {
match self.roundtrip(method, &params) {
Ok(v) => Ok(v),
Err((e, sent)) if e.is_lost_connection() => {
self.conn = None;
if sent && !is_read_only(method) {
return Err(RemoteError::Unconfirmed { method: method.to_owned(), source: Box::new(e) });
}
self.roundtrip(method, &params).map_err(|(e, _)| e)
}
Err((e, _)) => Err(e),
}
}
}
/// Connects to `addr` (`host:port`), trying each address it resolves to.
fn connect(addr: &str) -> Result<Connection, RemoteError> {
let socks = addr.to_socket_addrs().map_err(|source| RemoteError::Resolve { addr: addr.to_owned(), source })?;
let mut last = None;
for sock in socks {
match TcpStream::connect_timeout(&sock, Duration::from_millis(800)) {
Ok(s) => {
s.set_read_timeout(Some(Duration::from_secs(120)))?;
let r = s.try_clone()?;
return Ok((s, BufReader::new(r)));
}
Err(e) => last = Some(e),
}
}
Err(match last {
Some(source) => RemoteError::Unreachable { addr: addr.to_owned(), source },
None => RemoteError::NoAddress { addr: addr.to_owned() },
})
}
/// Methods that only read state, so repeating them after a lost reply is harmless.
fn is_read_only(method: &str) -> bool {
matches!(method, "session.inspect" | "document.inspect" | "engine.commands" | "engine.parity" | "ui.inspect" | "ui.menu.list")
}
impl Backend for Remote {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
match self.roundtrip(method, &params) {
Ok(v) => Ok(v),
Err(e) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
// Retry once on a fresh connection.
self.conn = None;
self.roundtrip(method, &params)
}
Err(e) => Err(e),
}
self.request(method, params).map_err(|e| e.to_string())
}
fn has_ui(&self) -> bool {
true
@@ -149,7 +265,56 @@ mod tests {
#[test]
fn remote_reports_unreachable() {
let mut r = Remote::new("127.0.0.1:1");
assert!(r.call("session.inspect", json!({})).is_err());
let mut r = Remote::new(&ControlTarget { host: Some("127.0.0.1".into()), port: 1 });
let e = r.request("session.inspect", json!({})).unwrap_err();
assert!(matches!(e, RemoteError::Unreachable { .. }), "{e:?}");
assert!(e.to_string().starts_with("cannot reach SoundCraft at 127.0.0.1:1: "), "{e}");
}
#[test]
fn remote_resolves_hostnames() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let mut r = Remote::new(&ControlTarget { host: Some("localhost".into()), port });
assert!(r.connection().is_ok());
}
/// Fake app: accepts one connection per entry in `replies`, counts the request line it reads,
/// then either drops the connection (`false`) or answers ok (`true`).
fn fake_app(replies: Vec<bool>) -> (ControlTarget, Arc<std::sync::atomic::AtomicUsize>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() };
let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter = seen.clone();
std::thread::spawn(move || {
for answer in replies {
let Ok((mut sock, _)) = listener.accept() else { return };
let mut line = String::new();
if BufReader::new(sock.try_clone().unwrap()).read_line(&mut line).unwrap_or(0) > 0 {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if answer {
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": true, "result": {"done": true}}));
}
}
}
});
(addr, seen)
}
#[test]
fn remote_does_not_repeat_a_mutation_after_a_lost_reply() {
let (addr, seen) = fake_app(vec![false, true]);
let mut r = Remote::new(&addr);
let err = r.call("engine.execute", json!({"command": "track.new"})).unwrap_err();
assert!(err.contains("may or may not have been executed"), "{err}");
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn remote_retries_a_read_after_a_lost_reply() {
let (addr, seen) = fake_app(vec![false, true]);
let mut r = Remote::new(&addr);
assert!(r.call("session.inspect", json!({})).is_ok());
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
}
}
+29 -8
View File
@@ -2,10 +2,12 @@
use crate::Backend;
use serde_json::{Value, json};
use soundcraft_audio_io::FileFormat;
use std::io::{BufRead, Write};
pub const PROTOCOL_VERSION: &str = "2025-06-18";
const SUPPORTED: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
const MAX_BATCH_CALLS: usize = 1000;
const INSTRUCTIONS: &str = "SoundCraft is a digital audio workstation (Pro Tools-class). Everything a user can do is a \
command: call `list_commands` to discover ids and their params, then `execute` (or `batch`). Positions are samples \
@@ -37,8 +39,8 @@ pub fn tool_definitions() -> Vec<Value> {
),
tool(
"batch",
"Run several commands in order; stops at the first error unless keep_going.",
json!({"calls": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
"Run up to 1000 commands in order; stops at the first error unless keep_going. Larger batches are rejected before any command runs.",
json!({"calls": {"type": "array", "maxItems": MAX_BATCH_CALLS, "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
&["calls"],
),
tool(
@@ -54,7 +56,12 @@ pub fn tool_definitions() -> Vec<Value> {
&[],
),
tool("open_session", "Open a .scraft session file.", json!({"path": {"type": "string"}}), &["path"]),
tool("save_session", "Save the session (path required the first time).", json!({"path": {"type": "string"}}), &[]),
tool(
"save_session",
"Save the session (path required the first time; replace: true to save over another existing file).",
json!({"path": {"type": "string"}, "replace": {"type": "boolean"}}),
&[],
),
tool(
"import_audio",
"Import an audio file (WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF) onto a new or existing track.",
@@ -64,7 +71,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"bounce_mix",
"Render the mix to an audio file. Returns peak dBFS and integrated LUFS.",
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": ["wav", "aiff", "flac"]}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": FileFormat::writable_extensions()}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
&["path"],
),
tool("parity", "Feature-parity report against the incumbent's menu catalog.", json!({}), &[]),
@@ -127,11 +134,23 @@ impl<B: Backend> Server<B> {
}
"batch" => {
let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false);
let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default();
let Some(calls) = args.get("calls").and_then(Value::as_array) else {
return error_result("`calls` must be an array");
};
if calls.len() > MAX_BATCH_CALLS {
return error_result(&format!("`calls` must contain at most {MAX_BATCH_CALLS} commands; no commands were executed"));
}
let mut out = Vec::new();
let mut failed = false;
for c in calls.iter().take(1000) {
let cmd = c.get("command").and_then(Value::as_str).unwrap_or("");
for c in calls {
let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
out.push(json!({"command": "", "ok": false, "error": "`command` required"}));
failed = true;
if !keep {
break;
}
continue;
};
let r = self.exec(cmd, c.get("params").cloned().unwrap_or(json!({})));
match r {
Ok(v) => out.push(json!({"command": cmd, "ok": true, "result": v})),
@@ -144,7 +163,9 @@ impl<B: Backend> Server<B> {
}
}
}
if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
// Any failure sets isError so agents that only check that flag notice;
// keep_going still runs the rest and the payload lists every call.
if failed { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
}
"inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})),
"new_session" => self.exec("session.new", args.clone()),
@@ -31,6 +31,8 @@ fn handshake_and_tool_list() {
assert_eq!(r["result"]["serverInfo"]["name"], "soundcraft");
let r = rpc(&mut s, 2, "tools/list", json!({}));
assert!(r["result"]["tools"].as_array().unwrap().len() >= 10);
let batch = r["result"]["tools"].as_array().unwrap().iter().find(|tool| tool["name"] == "batch").unwrap();
assert_eq!(batch["inputSchema"]["properties"]["calls"]["maxItems"], 1000);
assert!(s.handle(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).is_none());
let r = rpc(&mut s, 3, "nope", json!({}));
assert_eq!(r["error"]["code"], -32601);
@@ -135,3 +137,71 @@ fn errors_are_reported_not_panics() {
let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}}));
assert_eq!(r["result"]["isError"], true);
}
#[test]
fn batch_keep_going_still_sets_is_error_on_partial_failure() {
let mut s = Server::new(Headless::new(Engine::default()));
let r = rpc(
&mut s,
1,
"tools/call",
json!({"name": "batch", "arguments": {"keep_going": true, "calls": [
{"command": "track.new", "params": {"count": 1, "name": "Kept"}},
{"command": "mix.volume", "params": {"track": "Nope", "db": -6}},
{"command": "track.new", "params": {"count": 1, "name": "After"}},
]}}),
);
assert_eq!(r["result"]["isError"], true, "{r}");
let text = r["result"]["content"][0]["text"].as_str().unwrap();
let results: Value = serde_json::from_str(text).unwrap();
let arr = results.as_array().unwrap();
assert_eq!(arr.len(), 3, "{results}");
assert_eq!(arr[0]["ok"], true);
assert_eq!(arr[1]["ok"], false);
assert_eq!(arr[2]["ok"], true);
let sess = session(&mut s);
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "Kept"));
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "After"));
}
#[test]
fn batch_rejects_non_array_calls() {
let mut s = Server::new(Headless::new(Engine::default()));
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": {"command": "track.new"}}}));
assert_eq!(r["result"]["isError"], true, "{r}");
let text = r["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("`calls` must be an array"), "{text}");
assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0);
}
#[test]
fn batch_rejects_oversize_calls_before_any_mutation() {
for keep_going in [false, true] {
let mut s = Server::new(Headless::new(Engine::default()));
let before = session(&mut s);
let mut calls = vec![json!({"command": "engine.history"}); 1001];
calls[0] = json!({"command": "track.new", "params": {"name": "Must not be created"}});
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": calls, "keep_going": keep_going}}));
assert_eq!(session(&mut s), before, "oversize batch mutated the session (keep_going={keep_going})");
assert!(s.backend.engine.journal.is_empty());
assert!(!s.backend.engine.can_undo());
assert_eq!(r["result"]["isError"], true);
let text = r["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("1000"), "{text}");
}
}
#[test]
fn batch_accepts_empty_and_limit_sized_calls_in_order() {
for count in [0, 1000] {
let mut s = Server::new(Headless::new(Engine::default()));
let calls: Vec<_> = (0..count).map(|at| json!({"command": "session.format_time", "params": {"at": at, "format": "samples"}})).collect();
let results = tool(&mut s, "batch", json!({"calls": calls}));
let results = results.as_array().unwrap();
assert_eq!(results.len(), count);
for (at, result) in results.iter().enumerate() {
assert_eq!(result["ok"], true);
assert_eq!(result["result"]["text"], at.to_string());
}
}
}
@@ -0,0 +1,351 @@
//! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads
//! stays inside the read root, every file it writes inside the write root.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
use serde_json::{Value, json};
use soundcraft_automation::{Headless, mcp::Server};
use soundcraft_engine::Engine;
use soundcraft_engine::file_access::AutomationRoots;
use std::path::{Path, PathBuf};
/// A marker that must never come back from a file outside the read root.
const SECRET: &str = "TOP-SECRET-MARKER-5f3a";
struct Fixture {
base: PathBuf,
read: PathBuf,
write: PathBuf,
outside: PathBuf,
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
fn s(p: impl AsRef<Path>) -> String {
p.as_ref().to_string_lossy().into_owned()
}
/// `in/` (read root), `out/` (write root) and `outside/`, each holding the same real files:
/// a session with audio, a WAV, a MIDI file, a track preset and a clip group file. `outside/`
/// also holds text files holding [`SECRET`] under the names a command might open.
fn fixture(name: &str) -> Fixture {
let base = std::env::temp_dir().join(format!("sc-roots-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (read, write, outside) = (base.join("in"), base.join("out"), base.join("outside"));
for d in [&read, &write, &outside] {
std::fs::create_dir_all(d).unwrap();
}
// Real files, made by an unconfined engine.
let mut e = soundcraft_engine::demo::demo_engine();
for dir in [&read, &outside] {
let run = |e: &mut Engine, id: &str, p: Value| e.execute(id, &p).unwrap_or_else(|err| panic!("{id}: {err}"));
run(&mut e, "file.bounce_mix", json!({"path": s(dir.join("tone.wav")), "start": 0, "end": 4800}));
run(&mut e, "file.export_midi", json!({"path": s(dir.join("keys.mid")), "tracks": ["Keys"]}));
run(&mut e, "edit.select", json!({"tracks": ["Bass"]}));
run(&mut e, "track.save_preset", json!({"path": s(dir.join("bass.json"))}));
run(&mut e, "session.save_as", json!({"path": s(dir.join("song.scraft")), "replace": true}));
let clips = e.session().track_by_name("Kick").unwrap().clips().iter().map(|c| c.id.0).collect::<Vec<_>>();
run(&mut e, "file.export_clip_groups", json!({"path": s(dir.join("group.scgrp")), "clips": clips}));
}
for f in ["secret.wav", "secret.mid", "secret.scraft", "secret.json", "secret.scgrp", "secret.mp4"] {
std::fs::write(outside.join(f), SECRET).unwrap();
}
Fixture { base, read, write, outside }
}
fn server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
Server::new(Headless::new(Engine::default()).with_automation_roots(roots))
}
/// One `execute` call: whether it failed and the text of the result.
fn run(s: &mut Server<Headless>, command: &str, params: Value) -> (bool, String) {
let r = s
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "execute", "arguments": {"command": command, "params": params}}}))
.unwrap();
let res = &r["result"];
(res["isError"].as_bool().unwrap(), res["content"][0]["text"].as_str().unwrap().to_string())
}
fn ok(s: &mut Server<Headless>, command: &str, params: Value) -> String {
let (failed, text) = run(s, command, params);
assert!(!failed, "{command} failed: {text}");
text
}
fn refused(s: &mut Server<Headless>, command: &str, params: Value, needle: &str) {
let (failed, text) = run(s, command, params.clone());
assert!(failed, "{command} {params} must be refused, got {text}");
assert!(text.contains(needle), "{command}: {text}");
assert!(!text.contains(SECRET), "{command} leaked file contents: {text}");
}
/// Every file under `dir`, to show nothing was written.
fn listing(dir: &Path) -> Vec<String> {
let mut out = vec![];
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let p = entry.path();
if p.is_dir() {
out.extend(listing(&p));
}
out.push(s(&p));
}
out.sort();
out
}
fn inspect(s: &mut Server<Headless>) -> Value {
let r = s
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "inspect_session", "arguments": {"detail": "full"}}}))
.unwrap();
serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()
}
/// Every command that reads a user-named file, with params for a file in `dir`.
fn reads(dir: &Path, name: &dyn Fn(&str) -> String) -> Vec<(&'static str, Value)> {
let p = |f: &str| s(dir.join(name(f)));
vec![
("session.open", json!({"path": p("song.scraft")})),
("file.import_audio", json!({"path": p("tone.wav")})),
("file.import_audio", json!({"paths": [p("tone.wav")]})),
("file.import_midi", json!({"path": p("keys.mid")})),
("file.import_video", json!({"path": p("movie.mp4")})),
("file.import_clip_groups", json!({"path": p("group.scgrp")})),
("file.import_session_data", json!({"path": p("song.scraft")})),
("track.load_preset", json!({"path": p("bass.json"), "tracks": ["Bass"]})),
]
}
#[test]
fn reads_outside_the_read_root_are_refused_and_nothing_reaches_the_result() {
let f = fixture("reads");
let mut srv = server(Some(&f.read), Some(&f.write));
ok(&mut srv, "session.new", json!({"name": "Work"}));
ok(&mut srv, "track.new", json!({"name": "Bass", "count": 1}));
ok(&mut srv, "track.new", json!({"name": "Pads", "count": 1, "kind": "instrument"}));
let before = inspect(&mut srv);
// The secret files hold the marker under the names the commands read, so a read would show.
let secret_name = |f: &str| format!("secret.{}", f.rsplit('.').next().unwrap());
for (command, params) in reads(&f.outside, &secret_name) {
refused(&mut srv, command, params, "outside the read root");
}
refused(&mut srv, "mix.sampler_load", json!({"track": "Pads", "path": s(f.outside.join("secret.wav"))}), "outside the read root");
// The open session is exactly as it was: nothing was imported or opened.
assert_eq!(inspect(&mut srv), before);
// The same commands work on files inside the root.
ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
ok(&mut srv, "file.import_midi", json!({"path": s(f.read.join("keys.mid"))}));
ok(&mut srv, "file.import_clip_groups", json!({"path": s(f.read.join("group.scgrp"))}));
ok(&mut srv, "file.import_session_data", json!({"path": s(f.read.join("song.scraft")), "tracks": ["Kick"]}));
ok(&mut srv, "track.load_preset", json!({"path": s(f.read.join("bass.json")), "tracks": ["Bass"]}));
ok(&mut srv, "mix.sampler_load", json!({"track": "Pads", "path": s(f.read.join("tone.wav"))}));
// A missing movie inside the root is an ordinary error, not a refusal.
let (failed, text) = run(&mut srv, "file.import_video", json!({"path": s(f.read.join("movie.mp4"))}));
assert!(failed && !text.contains("read root"), "{text}");
ok(&mut srv, "session.open", json!({"path": s(f.read.join("song.scraft"))}));
// The typed tools take the same path through the same checks.
let r = srv
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "open_session", "arguments": {"path": s(f.outside.join("secret.scraft"))}}}))
.unwrap();
assert_eq!(r["result"]["isError"], true);
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root"));
}
/// A clip of the demo session.
fn kick_clip() -> u64 {
soundcraft_engine::demo::demo_engine().session().track_by_name("Kick").unwrap().clips()[0].id.0
}
/// Every command that writes a user-named file or folder, with params for `dir`.
fn writes(dir: &Path) -> Vec<(&'static str, Value)> {
let p = |f: &str| s(dir.join(f));
let clip = kick_clip();
vec![
("session.save", json!({"path": p("a.scraft")})),
("session.save_as", json!({"path": p("b.scraft")})),
("session.save_copy", json!({"path": p("c.scraft")})),
("session.save_template", json!({"path": p("d.scraft")})),
("file.bounce_mix", json!({"path": p("e.wav"), "start": 0, "end": 4800})),
("file.bounce_stems", json!({"dir": p("stems"), "start": 0, "end": 4800})),
("file.export_midi", json!({"path": p("f.mid")})),
("file.export_session_text", json!({"path": p("g.txt")})),
("file.export_clips", json!({"dir": p("clips"), "clips": [clip]})),
("file.export_selected_tracks_as_session", json!({"path": p("h.scraft"), "tracks": ["Kick"]})),
("file.export_sibelius", json!({"path": p("i.musicxml")})),
("file.send_to_sibelius", json!({"path": p("j.musicxml")})),
("file.print_score", json!({"path": p("k.svg")})),
("file.export_clip_groups", json!({"path": p("l.scgrp"), "clips": [clip]})),
("track.save_preset", json!({"path": p("m.json"), "tracks": ["Kick"]})),
]
}
fn demo_server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
Server::new(Headless::new(soundcraft_engine::demo::demo_engine()).with_automation_roots(roots))
}
#[test]
fn every_writing_command_is_refused_outside_the_write_root_and_writes_nothing() {
let f = fixture("writes");
let mut srv = demo_server(Some(&f.read), Some(&f.write));
let before = listing(&f.base);
let outside_before = listing(&f.outside);
for (command, params) in writes(&f.outside) {
refused(&mut srv, command, params, "outside the write root");
}
// Not even the temporary file a save goes through, nor a folder.
assert_eq!(listing(&f.base), before, "nothing may appear anywhere");
// With no path, the score goes to the temp folder: outside the write root, so refused too.
refused(&mut srv, "file.send_to_sibelius", json!({}), "outside the write root");
refused(&mut srv, "file.print_score", json!({}), "outside the write root");
assert_eq!(listing(&f.base), before);
// Inside the write root every one of them works.
for (command, params) in writes(&f.write) {
let text = ok(&mut srv, command, params);
assert!(!text.is_empty(), "{command}");
}
assert!(f.write.join("e.wav").is_file() && f.write.join("stems").is_dir() && f.write.join("l.scgrp").is_file());
// A save puts the audio in `Audio Files/` beside it, inside the root as well.
assert!(f.write.join("Audio Files").is_dir());
assert_eq!(listing(&f.outside), outside_before, "outside is as it was");
}
#[test]
fn saving_over_an_existing_file_outside_does_not_report_that_it_exists() {
let f = fixture("exists");
let mut srv = demo_server(Some(&f.read), Some(&f.write));
// Without `replace`, an existing target is reported as existing: not for a path outside the root.
refused(&mut srv, "session.save_as", json!({"path": s(f.outside.join("song.scraft"))}), "outside the write root");
}
#[test]
fn the_typed_bounce_and_save_tools_are_confined() {
let f = fixture("typed");
let mut srv = demo_server(Some(&f.read), Some(&f.write));
for (tool, args) in [
("bounce_mix", json!({"path": s(f.outside.join("x.wav")), "start": 0, "end": 4800})),
("save_session", json!({"path": s(f.outside.join("x.scraft"))})),
] {
let r = srv.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}})).unwrap();
assert_eq!(r["result"]["isError"], true, "{tool}");
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the write root"), "{tool}");
}
assert!(!f.outside.join("x.wav").exists() && !f.outside.join("x.scraft").exists());
let r = srv
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "bounce_mix", "arguments": {"path": s(f.write.join("x.wav")), "start": 0, "end": 4800}}}))
.unwrap();
assert_eq!(r["result"]["isError"], false);
}
#[test]
fn one_root_grants_only_its_own_access() {
let f = fixture("one-root");
// Read root only: reads work, nothing can be written anywhere.
let mut srv = demo_server(Some(&f.read), None);
ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
let before = listing(&f.base);
for (command, params) in writes(&f.write) {
refused(&mut srv, command, params, "write authority is absent");
}
for (command, params) in writes(&f.read) {
refused(&mut srv, command, params, "write authority is absent");
}
assert_eq!(listing(&f.base), before);
// Write root only: writes work, nothing can be read anywhere.
let mut srv = demo_server(None, Some(&f.write));
for (command, params) in writes(&f.write) {
ok(&mut srv, command, params);
}
let name = |f: &str| f.to_string();
for (command, params) in reads(&f.read, &name) {
refused(&mut srv, command, params, "read authority is absent");
}
refused(&mut srv, "file.import_audio", json!({"path": s(f.write.join("e.wav"))}), "read authority is absent");
}
#[test]
fn the_roots_may_be_the_same_folder_and_are_compared_by_whole_names() {
let f = fixture("same");
let mut srv = demo_server(Some(&f.read), Some(&f.read));
ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
ok(&mut srv, "file.bounce_mix", json!({"path": s(f.read.join("again.wav")), "start": 0, "end": 4800}));
// `in2` shares the prefix of `in` and is a different folder.
let sibling = f.base.join("in2");
std::fs::create_dir_all(&sibling).unwrap();
std::fs::copy(f.read.join("tone.wav"), sibling.join("tone.wav")).unwrap();
refused(&mut srv, "file.import_audio", json!({"path": s(sibling.join("tone.wav"))}), "outside the read root");
refused(&mut srv, "file.bounce_mix", json!({"path": s(sibling.join("x.wav"))}), "outside the write root");
// `..` out of the root is outside it.
let up = format!("{}/../outside/tone.wav", s(&f.read));
refused(&mut srv, "file.import_audio", json!({"path": up}), "outside the read root");
}
#[cfg(unix)]
#[test]
fn links_out_of_the_roots_are_refused() {
use std::os::unix::fs::symlink;
let f = fixture("links");
symlink(f.outside.join("tone.wav"), f.read.join("leak.wav")).unwrap();
symlink(&f.outside, f.write.join("away")).unwrap();
symlink(f.outside.join("not-yet.wav"), f.write.join("dangling.wav")).unwrap();
let mut srv = demo_server(Some(&f.read), Some(&f.write));
refused(&mut srv, "file.import_audio", json!({"path": s(f.read.join("leak.wav"))}), "outside the read root");
let before = listing(&f.outside);
refused(&mut srv, "file.bounce_mix", json!({"path": s(f.write.join("away/x.wav"))}), "outside the write root");
refused(&mut srv, "file.bounce_stems", json!({"dir": s(f.write.join("away/stems"))}), "outside the write root");
refused(&mut srv, "session.save_as", json!({"path": s(f.write.join("away/x.scraft"))}), "outside the write root");
refused(&mut srv, "file.bounce_mix", json!({"path": s(f.write.join("dangling.wav"))}), "a link that leads nowhere");
assert_eq!(listing(&f.outside), before, "nothing was written through a link");
assert!(!f.outside.join("not-yet.wav").exists());
// A link to a folder inside the root stays inside it.
symlink(f.read.join("."), f.base.join("in-alias")).unwrap();
let mut via_alias = demo_server(Some(&f.base.join("in-alias")), None);
ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
}
/// A session file is hostile input too: media it names outside the read root is not loaded,
/// and the refusal is listed with the other missing media.
#[test]
fn media_a_session_names_outside_the_read_root_is_not_loaded() {
let f = fixture("media");
let session = f.read.join("song.scraft");
let text = std::fs::read_to_string(&session).unwrap();
let outside_wav = s(f.outside.join("tone.wav"));
let swapped = {
let v: Value = serde_json::from_str(&text).unwrap();
let mut rel = vec![];
for src in v["sources"].as_array().unwrap() {
rel.push(src["path"].as_str().unwrap().to_string());
}
assert!(!rel.is_empty());
rel.iter().fold(text.clone(), |t, r| t.replace(&format!("\"{r}\""), &format!("{}", json!(outside_wav))))
};
std::fs::write(&session, swapped).unwrap();
let mut srv = server(Some(&f.read), None);
let opened = ok(&mut srv, "session.open", json!({"path": s(&session)}));
let opened: Value = serde_json::from_str(&opened).unwrap();
let missing = opened["missing"].as_array().unwrap();
assert!(!missing.is_empty() && missing.iter().all(|m| m.as_str().unwrap().contains("outside the read root")), "{opened}");
let sources = inspect(&mut srv)["sources"].clone();
assert!(sources.as_array().unwrap().iter().all(|x| x["loaded"] == false), "{sources}");
// The same session opened without roots does load the file it names.
let mut free = server(None, None);
ok(&mut free, "session.open", json!({"path": s(&session)}));
assert!(inspect(&mut free)["sources"].as_array().unwrap().iter().all(|x| x["loaded"] == true));
}
#[test]
fn without_roots_nothing_is_confined() {
let f = fixture("free");
let mut srv = server(None, None);
ok(&mut srv, "file.import_audio", json!({"path": s(f.outside.join("tone.wav"))}));
ok(&mut srv, "file.bounce_mix", json!({"path": s(f.outside.join("free.wav")), "start": 0, "end": 4800}));
assert!(f.outside.join("free.wav").is_file());
}

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