Compare commits

...
Author SHA1 Message Date
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
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
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
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
lucasegen e82de5cb97 Merge upstream main into fix/record-tooltip-f12 2026-10-23 22:22:02 +02: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
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
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
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 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
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
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 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 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
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
MarekSpodBiedry fb087c879b Give the New Tracks name field its own row so it stays wide (#626) 2026-10-15 17:14:38 +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 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
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 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
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
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
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
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 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 f9d04ffc98 Validate the full WAVE_FORMAT_EXTENSIBLE fmt chunk (#465) 2026-10-13 17:50:18 +02: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
338 changed files with 42177 additions and 21315 deletions
+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"
+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
+24
View File
@@ -21,3 +21,27 @@ jobs:
lint:
# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$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: 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')
+3
View File
@@ -16,6 +16,9 @@ concurrency:
jobs:
release:
# The shared workflow creates the draft release, which needs write access to contents.
permissions:
contents: write
# 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:
+4 -2
View File
@@ -26,8 +26,10 @@ permissions:
jobs:
windows-arm64:
# 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/windows-arm64.yml@main
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: soundcraft
app-dir: SoundCraft
install-relative-path: Programs/ArtCraft/SoundCraft
install-scope: perUser
+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
+1185 -297
View File
File diff suppressed because it is too large Load Diff
+37 -10
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.12.1"
version = "0.14.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -29,33 +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.6", 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"
# 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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
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 = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" }
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.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands.
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"] }
@@ -63,9 +70,11 @@ 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."*"]
@@ -81,3 +90,21 @@ 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.
+11 -10
View File
@@ -122,11 +122,11 @@ where things are.
- **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, 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.
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.
@@ -138,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.
@@ -184,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
@@ -216,8 +216,9 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo
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
@@ -387,4 +388,4 @@ Forks and modified versions must remove them.
<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.
+29 -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-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **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,12 +122,27 @@ 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)
@@ -161,9 +176,22 @@ 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) |
+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)
}
@@ -1,84 +0,0 @@
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
//! flag must not silently remove a root and leave the server unconfined.
use std::path::PathBuf;
#[derive(Default)]
pub struct AutomationArgs {
pub read_roots: Vec<PathBuf>,
pub write_roots: Vec<PathBuf>,
}
impl AutomationArgs {
/// Returns whether this argument is a root flag. Each flag may be given more than once (#874),
/// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let roots = match flag {
"--automation-read-root" => &mut self.read_roots,
"--automation-write-root" => &mut self.write_roots,
_ => return Ok(false),
};
let value = match inline {
Some(value) => value.to_owned(),
None => {
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
if value.starts_with('-') {
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
value
}
};
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
roots.push(value.into());
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_add_up_and_other_arguments_are_left() {
let mut roots = AutomationArgs::default();
let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--demo", &mut args).unwrap());
assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
assert_eq!(args.next().as_deref(), Some("song.scraft"));
// #874: a flag given again adds a folder, in either spelling.
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs { read_roots: vec!["read".into()], write_roots: vec!["write".into()] };
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
}
let mut roots = AutomationArgs::default();
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::default();
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
}
}
+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 -541
View File
@@ -1,545 +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] [--automation-read-root DIR]... [--automation-write-root DIR]...
//! MCP server on stdio. The roots confine every file the
//! agent's commands read (open, import, presets) and write
//! (save, bounce, export) to those folders, links followed;
//! each 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. They run the in-process server, so
//! they conflict with --connect.
//! 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)]
mod automation_args;
use serde_json::{Value, json};
use soundcraft_automation::{Backend, Headless, Remote, mcp::Server};
use soundcraft_engine::Engine;
use soundcraft_engine::file_access::{self, AutomationRoots};
use std::io::Write;
use std::process::ExitCode;
/// Print 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 {
($($t:tt)*) => {{
let mut o = std::io::stdout().lock();
let _ = writeln!(o, $($t)*);
}};
}
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(std::ffi::OsStr::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 rate = match parse_rate(arg_value(args, "--rate").as_deref()) {
Ok(r) => r,
Err(e) => return fail(e),
};
let bit_depth = match parse_bit_depth(arg_value(args, "--bit-depth").as_deref()) {
Ok(b) => b,
Err(e) => return fail(e),
};
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(std::ffi::OsStr::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) = rate {
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(std::ffi::OsStr::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 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),
}
}
fn parse_rate(v: Option<&str>) -> Result<Option<u32>, String> {
match v {
None => Ok(None),
Some(r) => match r.parse::<u32>() {
Ok(hz) if hz > 0 => Ok(Some(hz)),
_ => Err(format!("--rate: expected a sample rate in Hz, got `{r}`")),
},
}
}
fn parse_bit_depth(v: Option<&str>) -> Result<soundcraft_audio_io::BitDepth, String> {
match v {
None | Some("24") => Ok(soundcraft_audio_io::BitDepth::Int24),
Some("16") => Ok(soundcraft_audio_io::BitDepth::Int16),
Some("32") | Some("32f") => Ok(soundcraft_audio_io::BitDepth::Float32),
Some(b) => Err(format!("--bit-depth: expected 16, 24 or 32f, got `{b}`")),
}
}
/// 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, "replace": true})) {
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}`")),
}
}
/// 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}))
}
}
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. Control-channel methods stay methods.
let (m, p) = app_request(method, params);
match r.call(&m, p) {
Ok(v) => {
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
ExitCode::SUCCESS
}
Err(e) => fail(e),
}
}
/// What `mcp` was asked for.
struct McpArgs {
connect: Option<String>,
demo: bool,
roots: Option<std::sync::Arc<AutomationRoots>>,
}
/// Parse the `mcp` options up front: a malformed or misspelled root flag must not leave the
/// server running unconfined.
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
let mut connect = None;
let mut demo = false;
let mut roots_args = automation_args::AutomationArgs::default();
let mut it = args.iter().cloned();
while let Some(a) = it.next() {
if roots_args.consume(&a, &mut it)? {
continue;
}
match a.as_str() {
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
"--demo" => demo = true,
other if other.starts_with("--automation-") => return Err(format!("unknown mcp option `{other}`")),
_ => {}
}
}
let read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect();
let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect();
let roots = AutomationRoots::new(&read, &write)?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
so drop --connect (or use --connect without the roots)"
.into());
}
Ok(McpArgs { connect, demo, roots })
}
fn mcp(args: &[String]) -> ExitCode {
let McpArgs { connect, demo, roots } = match parse_mcp_args(args) {
Ok(a) => a,
Err(err) => return fail(err),
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let r = if let Some(port) = connect {
let mut s = Server::new(Remote::new(&port));
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());
eprintln!("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())
};
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 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);
}
}
#[test]
fn convert_rejects_invalid_rate_and_bit_depth() {
assert_eq!(parse_rate(None), Ok(None));
assert_eq!(parse_rate(Some("44100")), Ok(Some(44_100)));
assert!(parse_rate(Some("4410x")).is_err());
assert!(parse_rate(Some("0")).is_err());
assert!(matches!(parse_bit_depth(None), Ok(soundcraft_audio_io::BitDepth::Int24)));
assert!(matches!(parse_bit_depth(Some("16")), Ok(soundcraft_audio_io::BitDepth::Int16)));
assert!(matches!(parse_bit_depth(Some("32f")), Ok(soundcraft_audio_io::BitDepth::Float32)));
assert!(parse_bit_depth(Some("8")).is_err());
}
#[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("--automation-read-root DIR") && help.contains("--automation-write-root DIR"));
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);
}
+47 -9
View File
@@ -24,6 +24,20 @@ fn tmpdir(tag: &str) -> PathBuf {
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>) {
@@ -114,8 +128,7 @@ fn mcp_automation_roots_confine_the_server() {
/// 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() {
// Resolved, as the server shows the roots (`/var` is `/private/var` on macOS).
let base = std::fs::canonicalize(tmpdir("several")).unwrap();
let base = resolved(&tmpdir("several"));
for d in ["in", "in2", "out", "out2", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
@@ -196,13 +209,13 @@ fn mcp_automation_root_flags_are_checked_up_front() {
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"], "needs a folder");
fails(&["--automation-read-root"], "needs a folder");
fails(&["--automation-write-root", "--demo"], "needs a folder");
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="], "needs a non-empty folder");
fails(&["--automation-write-root="], "a value is required");
// A misspelled root flag must not start an unconfined server.
fails(&["--automation-read-roots", dir], "unknown mcp option");
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");
@@ -211,7 +224,32 @@ fn mcp_automation_root_flags_are_checked_up_front() {
#[test]
fn usage_lists_the_root_flags() {
let o = run(&["--help"]);
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}");
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);
}
+6
View File
@@ -1,5 +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}");
}
+3 -2
View File
@@ -15,10 +15,11 @@ 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 }
soundcraft-model = { workspace = true }
egui = { workspace = true }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
log = { workspace = true }
serde_json = { workspace = true }
rfd = { workspace = true }
+16 -16
View File
@@ -1,5 +1,6 @@
//! The browser shell: the eframe web runner around [`SoundApp`].
use soundcraft_audio_io::FileFormat;
use soundcraft_engine::{Engine, io};
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::sampler_window::SampleRead;
@@ -67,8 +68,6 @@ enum Purpose {
}
const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION];
const AUDIO_EXTS: &[&str] =
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"];
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
impl Purpose {
@@ -94,11 +93,11 @@ impl Purpose {
}
}
fn filter(self) -> (&'static str, &'static [&'static str]) {
fn filter(self) -> (&'static str, Vec<&'static str>) {
match self {
Self::Session => ("SoundCraft Session", SESSION_EXTS),
Self::Audio => ("Audio", AUDIO_EXTS),
Self::Midi => ("MIDI", MIDI_EXTS),
Self::Session => ("SoundCraft Session", SESSION_EXTS.to_vec()),
Self::Audio => ("Audio", FileFormat::readable_extensions()),
Self::Midi => ("MIDI", MIDI_EXTS.to_vec()),
}
}
}
@@ -130,18 +129,19 @@ impl WebShell {
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 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(ctx);
ctx.input_mut(|i| i.raw.dropped_files.clear());
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 {
@@ -208,8 +208,10 @@ impl eframe::App for WebShell {
self.take_path_prompt(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.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) {
@@ -225,17 +227,15 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
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();
opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() {
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase());
opts.set_type(match ext.as_deref() {
Some("scraftz") => "application/zip",
Some("wav") => "audio/wav",
Some("aif" | "aiff") => "audio/aiff",
Some("flac") => "audio/flac",
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",
_ => "application/octet-stream",
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)?;
+11 -2
View File
@@ -16,14 +16,20 @@ 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
@@ -35,3 +41,6 @@ winresource = "0.1"
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 }
+57 -32
View File
@@ -3,6 +3,8 @@
use std::ffi::OsString;
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";
@@ -15,21 +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.
/// 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 config_dir() -> Option<PathBuf> {
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
let exe = std::env::current_exe().ok();
resolve(exe.as_deref().and_then(Path::parent))
})
.clone()
}
fn resolve(exe_dir: Option<&Path>) -> 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) {
@@ -37,11 +31,12 @@ fn resolve(exe_dir: Option<&Path>) -> Option<PathBuf> {
} else {
Platform::Other
};
config_dir_with(platform, |name| std::env::var_os(name), exe_dir)
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>, exe_dir: Option<&Path>) -> Option<PathBuf> {
if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") {
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()) {
@@ -86,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))
}
@@ -111,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());
@@ -160,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();
@@ -201,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());
@@ -212,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()));
+27 -13
View File
@@ -10,7 +10,7 @@ use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
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
@@ -22,9 +22,15 @@ const MAX_LINE: usize = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_cei
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) => {
@@ -52,7 +58,9 @@ impl Drop for Slot {
}
}
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
/// 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 {
@@ -94,7 +102,9 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
Ok(true)
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
/// 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;
let mut reader = BufReader::new(read);
@@ -119,15 +129,19 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
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}")}),
};
@@ -175,7 +189,7 @@ mod tests {
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) = channel::<ControlRequest>();
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 {
+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}");
}
}
+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)]);
}
}
+4 -392
View File
@@ -413,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()?;
@@ -428,391 +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");
// 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);
}
}
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);
}
+69 -84
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,21 +13,26 @@
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, 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::new("window_title");
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));
@@ -35,26 +40,28 @@ fn set_title(ctx: &egui::Context, title: String) {
}
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.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: audio on an instrument track or a Sampler window loads
// the sampler; otherwise sessions open and audio/MIDI import.
let dropped: Vec<String> =
self.0.take_dropped_files(ctx).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);
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);
@@ -70,7 +77,7 @@ impl eframe::App for App {
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.0.ui);
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.
@@ -86,15 +93,17 @@ impl eframe::App for App {
};
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.guarded(|app| app.ui(ui));
}
fn on_exit(&mut self) {
self.0.close_autosave();
save_prefs(&self.0.ui);
save_prefs(&self.2, &self.0.ui);
if let Some(logger) = self.1 {
logger.report_audio_thread();
}
@@ -105,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") {
dialogs::run_or_ask(app, "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) => {
@@ -148,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}");
@@ -166,17 +151,13 @@ 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", "m4b", "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);
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", &["wav", "aif", "aiff", "flac"]),
"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()) {
@@ -185,11 +166,9 @@ fn services() -> Services {
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())
})),
pick_folder: Some(Box::new(|start: &str| {
rfd::FileDialog::new().set_directory(start).pick_folder().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())),
}
}
@@ -204,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}"),
@@ -246,7 +218,7 @@ fn main() -> eframe::Result {
}
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let prefs = load_prefs().unwrap_or_default();
let prefs = load_prefs(&env).unwrap_or_default();
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("SoundCraft")
@@ -263,6 +235,16 @@ fn main() -> eframe::Result {
// 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(), 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,
@@ -270,9 +252,11 @@ fn main() -> eframe::Result {
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.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 = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
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 {
@@ -280,7 +264,7 @@ fn main() -> eframe::Result {
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")]
@@ -297,6 +281,7 @@ fn main() -> eframe::Result {
Ok(Box::new(App(
app,
logger,
env,
#[cfg(target_os = "macos")]
menu,
)))
+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
+6
View File
@@ -55,3 +55,9 @@ sync_github_name = true
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"
+1 -1
View File
@@ -192,7 +192,7 @@ pub enum AuError {
#[cfg(target_os = "macos")]
mod api {
use super::{AuDescriptor, AuError, Plugin, PluginInfo};
use super::{AuDescriptor, AuError, Plugin, PluginInfo, registry};
pub fn scan() -> Vec<AuDescriptor> {
registry::scan()
+1 -1
View File
@@ -14,7 +14,7 @@ fn malformed(what: &str) -> AudioError {
AudioError::Malformed(format!("CAF {what} is larger than the file"))
}
/// Walk the chunks the way symphonia 0.5 reads them: it advances by the bytes it parses, not by
/// 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).
+116 -30
View File
@@ -61,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 {
@@ -115,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,
}
@@ -136,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> {
@@ -188,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" | "m4b" | "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.
@@ -266,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)
@@ -317,7 +396,14 @@ impl<W: Write + Seek> FileEncoder<W> {
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, opts.bwf.as_ref(), mask, false)?),
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"))),
+54 -6
View File
@@ -159,10 +159,17 @@ 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
}
}
#[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 {
@@ -193,8 +200,8 @@ pub(crate) fn write_interleaved(
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(&clamp_float(s).to_le_bytes()),
(true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()),
(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()),
}
@@ -206,9 +213,15 @@ pub(crate) fn write_interleaved(
Ok(())
}
/// 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) }
/// 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.
@@ -229,3 +242,38 @@ pub(crate) fn validate_layout(channels: usize, sample_rate: u32) -> Result<()> {
}
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}");
}
}
}
+484 -123
View File
@@ -1,9 +1,8 @@
//! 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::codecs::CodecParameters;
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,
@@ -11,9 +10,9 @@ use symphonia::core::codecs::audio::well_known::{
};
use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO};
use symphonia::core::errors::Error as SymError;
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes};
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 crate::pcm::{MAX_DECODE_SAMPLES, be_u32};
@@ -23,6 +22,45 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat
/// 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 {
@@ -44,61 +82,84 @@ enum Opened {
Empty(AudioInfo),
}
/// Skip a leading ID3v2 tag before probing.
///
/// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims, so the tag is skipped
/// with [`ReadBytes::ignore_bytes`] (which allocates nothing) instead of being handed to the
/// metadata reader. Returns the stream position the container starts at.
fn skip_leading_id3(mss: &mut MediaSourceStream<'_>, bytes: &[u8]) -> Result<usize> {
/// 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 {
let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX);
let head = bytes.get(at..).unwrap_or_default();
if head.len() < 10 || head.get(..3) != Some(b"ID3") {
return Ok(at);
match b.get(at..).and_then(crate::id3v2_len) {
Some(n) if n > 0 => at = at.saturating_add(n),
_ => return at,
}
let size = head.get(6..10).map(|size| size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f))).unwrap_or(0);
mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
}
}
/// How far symphonia's probe scans for a format marker before giving up.
const MAX_PROBE_DEPTH: usize = 1024 * 1024;
/// The container marker each detected format starts with, if its reader needs size checks first.
fn container_markers(detected: FileFormat) -> &'static [&'static [u8; 4]] {
match detected {
// RF64 and BW64 are the >4 GiB RIFF forms.
FileFormat::Wav => &[b"RIFF", b"RF64", b"BW64"],
FileFormat::Flac => &[b"fLaC"],
FileFormat::Caf => &[b"caff"],
// MP4/M4A put `ftyp` after the first atom's size field.
FileFormat::Aac | FileFormat::Alac => &[b"ftyp"],
// Unknown files may carry junk before the container; symphonia's probe scans for markers.
FileFormat::Other => &[b"caff", b"ftyp", b"fLaC", b"RIFF"],
FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => &[],
}
/// 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,
}
/// The probe short names a container marker stands for.
fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] {
match marker {
b"caff" => &["caf"],
// The isomp4 reader reads the `ftyp` atom.
b"ftyp" => &["isomp4"],
b"fLaC" => &["flac"],
b"RIFF" | b"RF64" | b"BW64" => &["wave"],
_ => &[],
}
/// 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"];
/// Find the first container marker 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_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_PROBE_DEPTH));
/// 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()) {
@@ -111,58 +172,211 @@ fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) ->
None
}
/// Probe for a container, checking the container it finds before its reader allocates whatever the
/// file claims.
///
/// The check has to run on the container the probe actually selects, but the probe reports that
/// container only through the reader it returns. The check therefore runs on the first marker the
/// detected format allows, and the reader is refused if its format is not the checked one: a
/// misleading marker must not let a later container slip through unchecked.
fn open(bytes: &[u8], detected: FileFormat) -> Result<Opened> {
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
let at = skip_leading_id3(&mut mss, bytes)?;
let mut checked: Option<&'static [&'static str]> = None;
if let Some((found, marker)) = find_marker(bytes, at, container_markers(detected)) {
match marker {
b"caff" => {
crate::caf::check(bytes, found)?;
checked = Some(expected_probe_format(marker));
}
b"ftyp" => {
crate::mp4::check(bytes, found.saturating_sub(4))?;
checked = Some(expected_probe_format(marker));
}
b"fLaC" => {
crate::flac::check_metadata(bytes, found)?;
if let Some(info) = crate::flac::empty_stream_info(bytes, found) {
return Ok(Opened::Empty(info));
}
checked = Some(expected_probe_format(marker));
}
b"RIFF" | b"RF64" | b"BW64" => {
crate::wav::check_info(bytes, found)?;
checked = Some(expected_probe_format(marker));
}
_ => {}
}
/// 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)),
}
let reader = symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
}
/// 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;
// 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.
if let Some(names) = checked.filter(|_| SIZE_CHECKED_PROBE_FORMATS.contains(&probed)) {
if !names.contains(&probed) {
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")));
}
} else if checked.is_none() && SIZE_CHECKED_PROBE_FORMATS.contains(&probed) {
return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
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))
}
/// The container `detected` stands for: the detection wins when it knows the file, otherwise the
/// decoded codec names the family.
fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat {
if detected != FileFormat::Other {
return detected;
@@ -213,16 +427,19 @@ fn sample_format_of(p: &AudioCodecParameters) -> 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, p) = reader
let track = reader
.tracks()
.iter()
.find_map(|t| match t.codec_params.as_ref().and_then(CodecParameters::audio) {
Some(p) if p.codec != CODEC_ID_NULL_AUDIO => Some((t, p)),
_ => None,
})
.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 = 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.as_ref().map(|c| c.count()).unwrap_or(0);
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
@@ -239,6 +456,39 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
))
}
/// 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], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
let mut reader = match open(bytes, detected)? {
@@ -249,28 +499,22 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
}
};
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.num_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)
.and_then(|t| t.codec_params.as_ref())
.and_then(CodecParameters::audio)
.cloned()
.ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
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, &AudioDecoderOptions::default()).map_err(map_err)?;
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 scratch: Vec<Vec<f32>> = Vec::new();
let mut interleaved: Vec<f32> = Vec::new();
let mut frames: u64 = 0;
let mut errors = 0usize;
loop {
@@ -280,10 +524,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
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;
}
};
@@ -294,24 +535,20 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
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);
@@ -321,10 +558,16 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
if count_only {
continue;
}
decoded.copy_to_vecs_planar::<f32>(&mut scratch);
for (dst, src) in planar.iter_mut().zip(&scratch) {
dst.extend(src.iter().map(|&s| 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()));
@@ -355,3 +598,121 @@ pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
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));
}
}
+72 -38
View File
@@ -10,6 +10,8 @@ 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).
@@ -50,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)
@@ -164,6 +178,45 @@ fn only_chunks_follow(b: &[u8], from: usize) -> bool {
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()));
@@ -195,9 +248,7 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
// 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 finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);
let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) };
let len = data_chunk_len(b, DataChunk { body, size32, is_rf64, remaining, declared });
if data.is_none() {
data = Some((body, len));
}
@@ -232,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);
@@ -355,17 +389,17 @@ pub(crate) struct Writer<W> {
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,
bwf: Option<&BwfInfo>,
mask: Option<u32>,
force_rf64: bool,
) -> Result<Self> {
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()?;
@@ -449,7 +483,7 @@ pub(crate) fn encode(
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, bwf, mask, force_rf64)?;
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())
}
+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");
}
}
}
-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}");
}
}
+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}");
}
}
}
}
+3 -1
View File
@@ -1,6 +1,8 @@
//! Integration tests, built as one test binary: each file is a module.
mod encode_format;
mod decode_read_list;
mod file_format;
mod float_headroom;
mod malformed;
mod roundtrip;
mod stream;
+233
View File
@@ -13,6 +13,17 @@ fn small(format: FileFormat, depth: BitDepth, channels: usize) -> Vec<u8> {
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();
@@ -314,6 +325,190 @@ fn an_id3_tag_before_the_audio_is_skipped_unread() {
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| {
@@ -511,3 +706,41 @@ proptest! {
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);
}
+2 -2
View File
@@ -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.
+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());
}
}
+135 -53
View File
@@ -5,14 +5,19 @@
//! 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 soundcraft_engine::file_access::{self, AutomationRoots};
use std::io::{BufRead, BufReader, Write};
use std::fmt;
use std::io::{self, BufRead, BufReader, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::time::Duration;
@@ -79,64 +84,151 @@ 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 socks: Vec<_> = self.addr.to_socket_addrs().map_err(|e| format!("{}: {e}", self.addr))?.collect();
let mut last = "no addresses found".to_string();
let mut stream = None;
for sock in &socks {
match TcpStream::connect_timeout(sock, Duration::from_millis(800)) {
Ok(s) => {
stream = Some(s);
break;
}
Err(e) => last = e.to_string(),
}
}
let s = stream.ok_or_else(|| format!("cannot reach SoundCraft at {}: {last}", 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(())
}
/// 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, (String, bool)> {
self.connect().map_err(|e| (e, false))?;
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_else(|| ("not connected".to_string(), false))?;
writeln!(w, "{line}").map_err(|e| (e.to_string(), false))?;
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(), true))?;
r.read_line(&mut buf).map_err(|e| (e.into(), true))?;
if buf.is_empty() {
return Err(("connection closed".into(), true));
return Err((RemoteError::Closed, true));
}
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.to_string(), true))?;
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(), true))
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.
@@ -146,19 +238,7 @@ fn is_read_only(method: &str) -> bool {
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, sent)) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
self.conn = None;
if sent && !is_read_only(method) {
// The app may already have executed the request; repeating it could apply it twice.
return Err(format!("{e}; `{method}` may or may not have been executed, check the session before repeating it"));
}
// Retry once on a fresh connection.
self.roundtrip(method, &params).map_err(|(e, _)| e)
}
Err((e, _)) => Err(e),
}
self.request(method, params).map_err(|e| e.to_string())
}
fn has_ui(&self) -> bool {
true
@@ -185,23 +265,25 @@ 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(&format!("localhost:{port}"));
assert_eq!(r.connect(), Ok(()));
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>) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
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 = listener.local_addr().unwrap().to_string();
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 || {
+2 -1
View File
@@ -2,6 +2,7 @@
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";
@@ -70,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!({}), &[]),
+3 -1
View File
@@ -32,7 +32,9 @@ libloading = "0.9"
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"
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
use super::gui::{GuiInner, GuiLink};
use super::host::{HostState, host_get_extension, host_request_callback, host_request_process, host_request_restart};
use super::instance::Instance;
use super::{
Arc, CLAP_EXT_AUDIO_PORTS, CLAP_EXT_GUI, CLAP_EXT_LATENCY, CLAP_EXT_NOTE_PORTS, CLAP_EXT_PARAMS, CLAP_EXT_STATE, CLAP_PLUGIN_FACTORY_ID,
CLAP_VERSION, CString, ClapError, EVENT_CAP, HashMap, MAX_FEATURES, MAX_PLUGINS_PER_FACTORY, Mutex, OnceLock, Path, PathBuf, PoisonError, c_void,
clap_host, clap_plugin_audio_ports, clap_plugin_entry, clap_plugin_factory, clap_plugin_gui, clap_plugin_latency, clap_plugin_note_ports,
clap_plugin_params, clap_plugin_state, clap_version_is_compatible, read_cstr, scan,
};
// ---- loaded binaries -----------------------------------------------------------------------
/// A plugin descriptor as reported by the factory.
#[derive(Debug, Clone)]
pub(crate) struct RawDescriptor {
pub id: String,
pub name: String,
pub vendor: String,
pub version: String,
pub description: String,
pub features: Vec<String>,
}
/// A loaded, initialised CLAP binary. Loaded binaries are cached for the life of the process
/// (see [`Bundle::load`]): plugin code must never be unmapped while an instance may still run.
pub(crate) struct Bundle {
entry: *const clap_plugin_entry,
factory: *const clap_plugin_factory,
// Dropped last: the library must outlive every pointer above.
_lib: libloading::Library,
}
// SAFETY: CLAP requires the entry and the plugin factory to be thread-safe; the raw pointers are
// read-only vtables inside the loaded library, which `_lib` keeps mapped.
unsafe impl Send for Bundle {}
// SAFETY: as above; `Bundle` has no interior mutability on the Rust side.
unsafe impl Sync for Bundle {}
fn loaded() -> &'static Mutex<HashMap<PathBuf, Arc<Bundle>>> {
static L: OnceLock<Mutex<HashMap<PathBuf, Arc<Bundle>>>> = OnceLock::new();
L.get_or_init(|| Mutex::new(HashMap::new()))
}
impl Bundle {
/// Loads (once per path, then cached for the life of the process) and initialises a `.clap`.
pub fn load(path: &Path) -> Result<Arc<Bundle>, ClapError> {
let mut map = loaded().lock().unwrap_or_else(PoisonError::into_inner);
if let Some(b) = map.get(path) {
return Ok(b.clone());
}
let b = Arc::new(Bundle::open(path)?);
map.insert(path.to_path_buf(), b.clone());
Ok(b)
}
fn open(path: &Path) -> Result<Bundle, ClapError> {
let err = |m: String| ClapError::Load(path.display().to_string(), m);
let exe = scan::bundle_executable(path).ok_or_else(|| err("no loadable binary".into()))?;
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
// hosting plugins; we only load files found in CLAP plugin folders or named explicitly.
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(e.to_string()))?;
// SAFETY: `clap_entry` is the CLAP-mandated exported data symbol of type
// `const clap_plugin_entry`; the symbol's address is therefore a `*const clap_plugin_entry`.
let entry: *const clap_plugin_entry = match unsafe { lib.get::<*const clap_plugin_entry>(b"clap_entry\0") } {
Ok(sym) => *sym,
Err(e) => return Err(err(format!("not a CLAP plugin: {e}"))),
};
if entry.is_null() {
return Err(err("null clap_entry".into()));
}
// SAFETY: non-null pointer to the entry struct inside the library, which `lib` keeps mapped.
let e = unsafe { *entry };
if !clap_version_is_compatible(e.clap_version) {
return Err(err(format!("unsupported CLAP version {}.{}", e.clap_version.major, e.clap_version.minor)));
}
let (Some(init), Some(get_factory)) = (e.init, e.get_factory) else { return Err(err("incomplete clap_entry".into())) };
let cpath = CString::new(path.to_string_lossy().into_owned()).map_err(|_| err("path contains NUL".into()))?;
// SAFETY: `init` comes from a valid entry; the path is a valid NUL-terminated string that
// outlives the call.
if !unsafe { init(cpath.as_ptr()) } {
return Err(err("clap_entry.init failed".into()));
}
// SAFETY: the entry is initialised; the id is a static NUL-terminated string.
let factory = unsafe { get_factory(CLAP_PLUGIN_FACTORY_ID.as_ptr()) } as *const clap_plugin_factory;
if factory.is_null() {
if let Some(deinit) = e.deinit {
// SAFETY: balanced with the successful `init` above.
unsafe { deinit() };
}
return Err(err("no plugin factory".into()));
}
Ok(Bundle { entry, factory, _lib: lib })
}
fn factory(&self) -> clap_plugin_factory {
// SAFETY: `factory` was non-null at load time and points into the still-mapped library.
unsafe { *self.factory }
}
/// The plugins this binary provides.
pub fn descriptors(&self) -> Vec<RawDescriptor> {
let f = self.factory();
let (Some(count), Some(get)) = (f.get_plugin_count, f.get_plugin_descriptor) else { return Vec::new() };
// SAFETY: vtable call with the factory pointer it belongs to.
let n = unsafe { count(self.factory) }.min(MAX_PLUGINS_PER_FACTORY);
let mut out = Vec::new();
for i in 0..n {
// SAFETY: index is below the reported count.
let d = unsafe { get(self.factory, i) };
if d.is_null() {
continue;
}
// SAFETY: non-null descriptor owned by the plugin library, valid while it is loaded.
let d = unsafe { *d };
// SAFETY: descriptor string fields are null or NUL-terminated (CLAP contract).
let Some(id) = (unsafe { read_cstr(d.id) }).filter(|s| !s.is_empty()) else { continue };
// SAFETY: as above.
let name = unsafe { read_cstr(d.name) }.unwrap_or_else(|| id.clone());
let mut features = Vec::new();
if !d.features.is_null() {
for k in 0..MAX_FEATURES {
// SAFETY: `features` is a NULL-terminated array; we stop at the terminator
// (or the cap) and never read past it.
let p = unsafe { *d.features.add(k) };
if p.is_null() {
break;
}
// SAFETY: a non-null entry of the feature array is a NUL-terminated string.
if let Some(s) = unsafe { read_cstr(p) } {
features.push(s);
}
}
}
out.push(RawDescriptor {
id,
name,
// SAFETY: descriptor string fields are null or NUL-terminated.
vendor: unsafe { read_cstr(d.vendor) }.unwrap_or_default(),
// SAFETY: as above.
version: unsafe { read_cstr(d.version) }.unwrap_or_default(),
// SAFETY: as above.
description: unsafe { read_cstr(d.description) }.unwrap_or_default(),
features,
});
}
out
}
/// Creates and initialises one plugin instance.
pub fn instantiate(self: &Arc<Self>, plugin_id: &str) -> Result<Instance, ClapError> {
let err = |m: &str| ClapError::Instantiate(plugin_id.to_string(), m.to_string());
let create = self.factory().create_plugin.ok_or_else(|| err("factory cannot create plugins"))?;
let cid = CString::new(plugin_id).map_err(|_| err("id contains NUL"))?;
let state = Arc::new(HostState::default());
let host = Box::new(clap_host {
clap_version: CLAP_VERSION,
host_data: Arc::as_ptr(&state).cast_mut().cast::<c_void>(),
name: c"SoundCraft".as_ptr(),
vendor: c"SoundCraft contributors".as_ptr(),
url: c"https://github.com/storytold/soundcraft".as_ptr(),
version: c"0.1.0".as_ptr(),
get_extension: Some(host_get_extension),
request_restart: Some(host_request_restart),
request_process: Some(host_request_process),
request_callback: Some(host_request_callback),
});
// SAFETY: the factory is valid; `host` is boxed (stable address) and owned by the returned
// `Instance`, which destroys the plugin before dropping the box; `cid` outlives the call.
let plugin = unsafe { create(self.factory, &*host, cid.as_ptr()) };
if plugin.is_null() {
return Err(err("factory returned no instance"));
}
let mut inst = Instance {
plugin,
params: std::ptr::null(),
ports: std::ptr::null(),
latency: std::ptr::null(),
notes: std::ptr::null(),
state_ext: std::ptr::null(),
gui: Arc::new(GuiLink::default()),
raw: Vec::with_capacity(EVENT_CAP),
out_params: Vec::with_capacity(EVENT_CAP),
active: false,
processing: false,
max_frames: 0,
created: true,
_host: host,
state,
_bundle: self.clone(),
};
// SAFETY: `plugin` is a fresh non-null instance; we copy its vtable struct.
let vt = unsafe { *plugin };
let ok = match vt.init {
// SAFETY: `init` is called exactly once, right after creation, as CLAP requires.
Some(init) => unsafe { init(plugin) },
None => false,
};
if !ok {
// `Drop` destroys the instance (without deactivating: it never activated).
return Err(err("plugin init failed"));
}
inst.params = inst.extension::<clap_plugin_params>(CLAP_EXT_PARAMS);
inst.ports = inst.extension::<clap_plugin_audio_ports>(CLAP_EXT_AUDIO_PORTS);
inst.latency = inst.extension::<clap_plugin_latency>(CLAP_EXT_LATENCY);
inst.notes = inst.extension::<clap_plugin_note_ports>(CLAP_EXT_NOTE_PORTS);
inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
inst.gui = Arc::new(GuiLink {
inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),
host: Arc::clone(&inst.state),
});
Ok(inst)
}
}
impl Drop for Bundle {
fn drop(&mut self) {
// SAFETY: `entry` was valid at load time and `_lib` (dropped after this) keeps it mapped.
let e = unsafe { *self.entry };
if let Some(deinit) = e.deinit {
// SAFETY: balanced with the successful `init` in `open`; no instance holds an `Arc`
// to this bundle any more (they keep it alive), so no plugin code still runs.
unsafe { deinit() };
}
}
}
+224
View File
@@ -0,0 +1,224 @@
use super::{
CLAP_BEATTIME_FACTOR, CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE,
CLAP_EVENT_TRANSPORT, CLAP_SECTIME_FACTOR, CLAP_TRANSPORT_HAS_BEATS_TIMELINE, CLAP_TRANSPORT_HAS_SECONDS_TIMELINE, CLAP_TRANSPORT_HAS_TEMPO,
CLAP_TRANSPORT_HAS_TIME_SIGNATURE, CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_RECORDING, MAX_STATE_BYTES, Transport, c_void, clap_event_header,
clap_event_midi, clap_event_note, clap_event_param_value, clap_event_transport, clap_input_events, clap_istream, clap_ostream,
clap_output_events,
};
// ---- events --------------------------------------------------------------------------------
/// A host → plugin event, safe to build anywhere; converted to CLAP structs inside `process`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Event {
Param {
time: u32,
id: u32,
value: f64,
},
NoteOn {
time: u32,
key: u8,
velocity: f64,
},
NoteOff {
time: u32,
key: u8,
},
/// A raw MIDI 1.0 message (for plugins whose note port speaks only MIDI).
Midi {
time: u32,
data: [u8; 3],
},
}
impl Event {
pub fn time(&self) -> u32 {
match *self {
Event::Param { time, .. } | Event::NoteOn { time, .. } | Event::NoteOff { time, .. } | Event::Midi { time, .. } => time,
}
}
pub fn set_time(&mut self, t: u32) {
match self {
Event::Param { time, .. } | Event::NoteOn { time, .. } | Event::NoteOff { time, .. } | Event::Midi { time, .. } => *time = t,
}
}
}
#[derive(Clone, Copy)]
pub(super) enum RawEvent {
Note(clap_event_note),
Param(clap_event_param_value),
Midi(clap_event_midi),
}
impl RawEvent {
fn header(&self) -> *const clap_event_header {
// The header is the first field of every `repr(C)` CLAP event struct, so a pointer to it is
// a pointer to the whole event, as CLAP expects.
match self {
RawEvent::Note(e) => &e.header,
RawEvent::Param(e) => &e.header,
RawEvent::Midi(e) => &e.header,
}
}
pub(super) fn from_event(e: &Event, offset: u32) -> RawEvent {
let header = |size: usize, type_: u16, time: u32| clap_event_header {
size: u32::try_from(size).unwrap_or(u32::MAX),
time: time.saturating_sub(offset),
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_,
flags: 0,
};
match *e {
Event::Param { time, id, value } => RawEvent::Param(clap_event_param_value {
header: header(size_of::<clap_event_param_value>(), CLAP_EVENT_PARAM_VALUE, time),
param_id: id,
cookie: std::ptr::null_mut(),
note_id: -1,
port_index: -1,
channel: -1,
key: -1,
value,
}),
Event::NoteOn { time, key, velocity } => RawEvent::Note(clap_event_note {
header: header(size_of::<clap_event_note>(), CLAP_EVENT_NOTE_ON, time),
note_id: -1,
port_index: 0,
channel: 0,
key: i16::from(key),
velocity,
}),
Event::NoteOff { time, key } => RawEvent::Note(clap_event_note {
header: header(size_of::<clap_event_note>(), CLAP_EVENT_NOTE_OFF, time),
note_id: -1,
port_index: 0,
channel: 0,
key: i16::from(key),
velocity: 0.0,
}),
Event::Midi { time, data } => {
RawEvent::Midi(clap_event_midi { header: header(size_of::<clap_event_midi>(), CLAP_EVENT_MIDI, time), port_index: 0, data })
}
}
}
}
pub(super) fn transport_event(t: &Transport) -> clap_event_transport {
let fixed = |x: f64, factor: i64| if x.is_finite() { (x * factor as f64).round() as i64 } else { 0 };
let mut flags =
CLAP_TRANSPORT_HAS_TEMPO | CLAP_TRANSPORT_HAS_BEATS_TIMELINE | CLAP_TRANSPORT_HAS_SECONDS_TIMELINE | CLAP_TRANSPORT_HAS_TIME_SIGNATURE;
if t.playing {
flags |= CLAP_TRANSPORT_IS_PLAYING;
}
if t.recording {
flags |= CLAP_TRANSPORT_IS_RECORDING;
}
clap_event_transport {
header: clap_event_header {
size: u32::try_from(size_of::<clap_event_transport>()).unwrap_or(u32::MAX),
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_TRANSPORT,
flags: 0,
},
flags,
song_pos_beats: fixed(t.quarters, CLAP_BEATTIME_FACTOR),
song_pos_seconds: fixed(t.seconds, CLAP_SECTIME_FACTOR),
tempo: t.tempo,
tempo_inc: 0.0,
loop_start_beats: 0,
loop_end_beats: 0,
loop_start_seconds: 0,
loop_end_seconds: 0,
bar_start: fixed(t.bar_start, CLAP_BEATTIME_FACTOR),
bar_number: i32::try_from(t.bar).unwrap_or(0),
tsig_num: u16::try_from(t.numerator).unwrap_or(4),
tsig_denom: u16::try_from(t.denominator).unwrap_or(4),
}
}
pub(super) unsafe extern "C" fn in_events_size(list: *const clap_input_events) -> u32 {
if list.is_null() {
return 0;
}
// SAFETY: `list` is the `clap_input_events` we built in `process`; its ctx is the event Vec.
let v = unsafe { ((*list).ctx as *const Vec<RawEvent>).as_ref() };
v.map_or(0, |v| u32::try_from(v.len()).unwrap_or(u32::MAX))
}
pub(super) unsafe extern "C" fn in_events_get(list: *const clap_input_events, index: u32) -> *const clap_event_header {
if list.is_null() {
return std::ptr::null();
}
// SAFETY: as in `in_events_size`; the Vec is not mutated while the plugin processes.
let v = unsafe { ((*list).ctx as *const Vec<RawEvent>).as_ref() };
v.and_then(|v| v.get(index as usize)).map_or(std::ptr::null(), RawEvent::header)
}
pub(super) unsafe extern "C" fn out_events_try_push(list: *const clap_output_events, event: *const clap_event_header) -> bool {
// Plugin → host events are accepted. Parameter values (typically edits in the plugin's GUI)
// are kept, while there is room, so the UI can write them into the session; the rest is
// ignored.
if list.is_null() || event.is_null() {
return true;
}
// SAFETY: CLAP passes a valid event header for the duration of the call.
let h = unsafe { &*event };
if h.space_id != CLAP_CORE_EVENT_SPACE_ID || h.type_ != CLAP_EVENT_PARAM_VALUE || (h.size as usize) < size_of::<clap_event_param_value>() {
return true;
}
// SAFETY: the header says this is a complete `clap_event_param_value` (type and size checked).
let e = unsafe { &*event.cast::<clap_event_param_value>() };
// SAFETY: `ctx` is null or the instance's preallocated `out_params` Vec, which `process`
// borrows exclusively for the duration of the plugin call.
if let Some(v) = unsafe { ((*list).ctx as *mut Vec<(u32, f64)>).as_mut() }
&& v.len() < v.capacity()
&& e.value.is_finite()
{
v.push((e.param_id, e.value));
}
true
}
// ---- state streams -------------------------------------------------------------------------
/// Read side of an in-memory state blob.
pub(super) struct ReadCursor<'a> {
pub(super) data: &'a [u8],
pub(super) pos: usize,
}
pub(super) unsafe extern "C" fn istream_read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
if stream.is_null() || buffer.is_null() {
return -1;
}
// SAFETY: `ctx` is the `ReadCursor` on `load_state`'s stack frame, alive during the call.
let Some(c) = (unsafe { ((*stream).ctx as *mut ReadCursor<'_>).as_mut() }) else { return -1 };
let rest = c.data.get(c.pos..).unwrap_or(&[]);
let n = rest.len().min(usize::try_from(size).unwrap_or(usize::MAX));
if let Some(src) = rest.get(..n) {
// SAFETY: the plugin provides `size` ≥ `n` writable bytes at `buffer`; `src` is `n`
// readable bytes of our own slice; they cannot overlap.
unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), buffer.cast::<u8>(), n) };
}
c.pos += n;
i64::try_from(n).unwrap_or(-1)
}
pub(super) unsafe extern "C" fn ostream_write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
if stream.is_null() || buffer.is_null() {
return -1;
}
// SAFETY: `ctx` is the output Vec on `save_state`'s stack frame, alive during the call.
let Some(v) = (unsafe { ((*stream).ctx as *mut Vec<u8>).as_mut() }) else { return -1 };
let Ok(n) = usize::try_from(size) else { return -1 };
if v.len().saturating_add(n) > MAX_STATE_BYTES {
return -1;
}
// SAFETY: the plugin provides `size` readable bytes at `buffer` for the call.
let src = unsafe { std::slice::from_raw_parts(buffer.cast::<u8>(), n) };
v.extend_from_slice(src);
i64::try_from(n).unwrap_or(-1)
}
+332
View File
@@ -0,0 +1,332 @@
use super::events::ostream_write;
use super::host::HostState;
use super::{
Arc, CStr, CString, Mutex, Ordering, PoisonError, c_void, clap_ostream, clap_plugin, clap_plugin_gui, clap_plugin_state, clap_window,
clap_window_handle, hostwin,
};
// ---- editor (clap.gui) ---------------------------------------------------------------------
/// True on the process's main thread (AppKit requires GUI calls there).
#[cfg(target_os = "macos")]
pub(crate) fn on_main_thread() -> bool {
unsafe extern "C" {
fn pthread_main_np() -> std::ffi::c_int;
}
// SAFETY: a libSystem function without arguments or preconditions.
unsafe { pthread_main_np() == 1 }
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn on_main_thread() -> bool {
true
}
/// The windowing API of this platform.
fn window_api() -> Option<&'static CStr> {
if cfg!(target_os = "macos") {
Some(clap_sys::ext::gui::CLAP_WINDOW_API_COCOA)
} else if cfg!(windows) {
Some(clap_sys::ext::gui::CLAP_WINDOW_API_WIN32)
} else if cfg!(all(unix, not(target_os = "android"))) {
Some(clap_sys::ext::gui::CLAP_WINDOW_API_X11)
} else {
None
}
}
pub(super) struct GuiInner {
pub(super) plugin: *const clap_plugin,
pub(super) gui: *const clap_plugin_gui,
pub(super) state: *const clap_plugin_state,
/// The plugin instance exists (cleared by `Instance::drop` before it destroys the plugin).
pub(super) alive: bool,
pub(super) created: bool,
pub(super) visible: bool,
/// The host window of an embedded editor (`None` for floating editors).
pub(super) window: Option<hostwin::HostWindow>,
}
/// The link between an instance and its editor and state handles. The instance may process on
/// the audio thread while the editor is driven and the state saved from the main thread: CLAP
/// allows `clap.gui` and `clap.state` (main-thread) calls concurrently with audio-thread calls.
/// Every such call happens under this lock, and the instance clears `alive` (destroying any GUI)
/// under the same lock before it destroys the plugin, so a handle can never call into a destroyed
/// plugin.
pub(crate) struct GuiLink {
pub(super) inner: Mutex<GuiInner>,
pub(super) host: Arc<HostState>,
}
impl Default for GuiLink {
fn default() -> Self {
GuiLink {
inner: Mutex::new(GuiInner {
plugin: std::ptr::null(),
gui: std::ptr::null(),
state: std::ptr::null(),
alive: false,
created: false,
visible: false,
window: None,
}),
host: Arc::new(HostState::default()),
}
}
}
// SAFETY: the raw pointers are only dereferenced under the mutex, while `alive` guarantees the
// plugin exists; CLAP's threading rules (main-thread GUI calls) are checked at each call.
unsafe impl Send for GuiInner {}
impl GuiLink {
fn lock(&self) -> std::sync::MutexGuard<'_, GuiInner> {
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn available(&self) -> bool {
let g = self.lock();
g.alive && !g.gui.is_null()
}
pub fn host(&self) -> &HostState {
&self.host
}
fn vt(g: &GuiInner) -> Option<clap_plugin_gui> {
// SAFETY: `gui` is null or the plugin's static extension vtable, valid while `alive`.
(g.alive).then(|| unsafe { g.gui.as_ref() }.copied()).flatten()
}
/// Opens (creates and shows) the plugin's floating editor window.
pub fn open(&self, title: &str) -> Result<(), String> {
if !on_main_thread() {
return Err("plugin editors must be opened on the main thread".into());
}
let mut g = self.lock();
let vt = GuiLink::vt(&g).ok_or_else(|| "this plugin has no editor".to_string())?;
if g.created {
if let Some(show) = vt.show {
// SAFETY: live plugin with a created GUI; main thread (checked).
unsafe { show(g.plugin) };
}
if let Some(w) = &g.window {
w.raise();
}
g.visible = true;
return Ok(());
}
let api = window_api().ok_or_else(|| "plugin editors are not supported on this platform".to_string())?;
let (Some(supported), Some(create)) = (vt.is_api_supported, vt.create) else { return Err("incomplete clap.gui extension".into()) };
// SAFETY: live plugin; `api` is a static NUL-terminated string; main thread.
if !unsafe { supported(g.plugin, api.as_ptr(), true) } {
// SAFETY: as above.
if hostwin::SUPPORTED && unsafe { supported(g.plugin, api.as_ptr(), false) } {
return self.open_embedded(&mut g, vt, api, title);
}
return Err("this plugin offers only an embedded editor, which SoundCraft hosts on Windows only".into());
}
self.host.gui_closed.store(false, Ordering::Relaxed);
self.host.gui_destroyed.store(false, Ordering::Relaxed);
// SAFETY: as above; no GUI exists yet.
if !unsafe { create(g.plugin, api.as_ptr(), true) } {
return Err("the plugin could not create its editor".into());
}
g.created = true;
if !cfg!(target_os = "macos")
&& let Some(f) = vt.set_scale
{
// SAFETY: live plugin with a created GUI. (Cocoa uses logical pixels: not called.)
unsafe { f(g.plugin, 1.0) };
}
if let (Some(f), Ok(t)) = (vt.suggest_title, CString::new(title)) {
// SAFETY: live plugin with a created floating GUI; `t` outlives the call.
unsafe { f(g.plugin, t.as_ptr()) };
}
let shown = match vt.show {
// SAFETY: as above.
Some(f) => unsafe { f(g.plugin) },
None => false,
};
if !shown {
GuiLink::destroy(&mut g);
return Err("the plugin could not show its editor".into());
}
g.visible = true;
Ok(())
}
/// Embeds the editor in a host window of ours (plugins that offer no floating editor).
fn open_embedded(&self, g: &mut GuiInner, vt: clap_plugin_gui, api: &CStr, title: &str) -> Result<(), String> {
let (Some(create), Some(set_parent)) = (vt.create, vt.set_parent) else { return Err("incomplete clap.gui extension".into()) };
self.host.gui_closed.store(false, Ordering::Relaxed);
self.host.gui_destroyed.store(false, Ordering::Relaxed);
self.host.gui_resize.store(0, Ordering::Relaxed);
// SAFETY: live plugin; `api` is a static NUL-terminated string; main thread; no GUI yet.
if !unsafe { create(g.plugin, api.as_ptr(), false) } {
return Err("the plugin could not create its editor".into());
}
g.created = true;
if let Some(f) = vt.set_scale {
// SAFETY: live plugin with a created GUI.
unsafe { f(g.plugin, 1.0) };
}
let (mut w, mut h) = (640u32, 480u32);
if let Some(f) = vt.get_size {
let (mut a, mut b) = (0u32, 0u32);
// SAFETY: live plugin with a created GUI; both out-pointers are valid locals.
if unsafe { f(g.plugin, &mut a, &mut b) } && a > 0 && b > 0 {
(w, h) = (a.min(16_384), b.min(16_384));
}
}
let win = match hostwin::HostWindow::new(title, f64::from(w), f64::from(h)) {
Ok(win) => win,
Err(e) => {
GuiLink::destroy(g);
return Err(e);
}
};
let parent = clap_window { api: api.as_ptr(), specific: clap_window_handle { win32: win.parent() } };
// SAFETY: live plugin with a created embedded GUI; `parent` names our live window and
// outlives the call.
if !unsafe { set_parent(g.plugin, &parent) } {
win.close();
GuiLink::destroy(g);
return Err("the editor could not attach to its window".into());
}
g.window = Some(win);
let shown = match vt.show {
// SAFETY: live plugin with a created, parented GUI.
Some(f) => unsafe { f(g.plugin) },
None => true,
};
if !shown {
GuiLink::destroy(g);
return Err("the plugin could not show its editor".into());
}
if let Some(win) = &g.window {
win.raise();
}
g.visible = true;
Ok(())
}
fn destroy(g: &mut GuiInner) {
if g.created
&& let Some(f) = GuiLink::vt(g).and_then(|v| v.destroy)
{
// SAFETY: live plugin with a created GUI; destroyed exactly once.
unsafe { f(g.plugin) };
}
g.created = false;
g.visible = false;
// The plugin's child window is gone; now its host window can go too.
if let Some(w) = g.window.take() {
w.close();
}
}
/// `clap_plugin_state.save` into memory (`None` without the extension, on failure, or once the
/// plugin is gone).
pub fn save_state(&self) -> Option<Vec<u8>> {
let g = self.lock();
if !g.alive {
return None;
}
// SAFETY: `state` is null or the plugin's static extension vtable, valid while `alive`.
let save = unsafe { g.state.as_ref() }?.save?;
let mut out: Vec<u8> = Vec::new();
let stream = clap_ostream { ctx: (&mut out as *mut Vec<u8>).cast::<c_void>(), write: Some(ostream_write) };
// SAFETY: live plugin (`alive`, under the lock); the stream and its context live on this
// frame for the call.
unsafe { save(g.plugin, &stream) }.then_some(out)
}
/// Closes (destroys) the editor.
pub fn close(&self) {
if !on_main_thread() {
return;
}
GuiLink::destroy(&mut self.lock());
}
pub fn is_open(&self) -> bool {
let g = self.lock();
g.alive && g.created && g.visible && g.window.as_ref().is_none_or(hostwin::HostWindow::is_visible)
}
/// Main-thread housekeeping: runs a requested `on_main_thread`, follows the plugin's
/// show/hide/closed requests, and returns the parameter edits reported since the last call.
pub fn idle(&self) -> Vec<(u32, f64)> {
if !on_main_thread() {
return Vec::new();
}
let mut g = self.lock();
if !g.alive {
return Vec::new();
}
if self.host.callback.swap(false, Ordering::Relaxed) {
// SAFETY: `plugin` is valid while `alive`; we copy its vtable struct.
if let Some(f) = unsafe { (*g.plugin).on_main_thread } {
// SAFETY: live plugin, main thread, requested via `request_callback`.
unsafe { f(g.plugin) };
}
}
if g.created {
let resize = self.host.gui_resize.swap(0, Ordering::Relaxed);
if resize != 0
&& let Some(win) = &g.window
{
let (w, h) = ((resize >> 32) as u32, resize as u32);
hostwin::resize(win.raw(), f64::from(w), f64::from(h));
if let Some(f) = GuiLink::vt(&g).and_then(|v| v.set_size) {
// SAFETY: live plugin with a created embedded GUI, main thread.
unsafe { f(g.plugin, w, h) };
}
}
if g.window.as_ref().is_some_and(|w| !w.is_visible()) {
// The user closed the embedded editor's host window: free the editor.
GuiLink::destroy(&mut g);
} else if self.host.gui_closed.swap(false, Ordering::Relaxed) {
// The user closed the floating window (or the plugin lost its GUI): free it.
self.host.gui_destroyed.store(false, Ordering::Relaxed);
GuiLink::destroy(&mut g);
} else if let Some(vt) = GuiLink::vt(&g) {
if self.host.gui_show.swap(false, Ordering::Relaxed)
&& let Some(f) = vt.show
{
// SAFETY: live plugin with a created GUI, main thread.
g.visible = unsafe { f(g.plugin) };
}
if self.host.gui_hide.swap(false, Ordering::Relaxed)
&& let Some(f) = vt.hide
{
// SAFETY: as above.
unsafe { f(g.plugin) };
g.visible = false;
}
}
}
drop(g);
let mut e = self.host.edits.lock().unwrap_or_else(PoisonError::into_inner);
let out = e.clone();
e.clear();
out
}
/// Called by the instance before it destroys the plugin.
pub(super) fn kill(&self) {
let mut g = self.lock();
if g.created {
if on_main_thread() {
GuiLink::destroy(&mut g);
} else {
log::warn!("clap: editor still open while its plugin is destroyed off the main thread");
g.created = false;
// Window calls are main-thread only: leave the host window rather than crash.
g.window = None;
}
}
g.alive = false;
}
}
+206
View File
@@ -0,0 +1,206 @@
use super::{
AtomicBool, AtomicU64, CLAP_EXT_GUI, CLAP_EXT_LATENCY, CLAP_EXT_LOG, CLAP_EXT_PARAMS, CLAP_EXT_STATE, CLAP_LOG_ERROR, CLAP_LOG_FATAL,
CLAP_LOG_WARNING, CStr, EDIT_CAP, Mutex, Ordering, c_char, c_void, clap_host, clap_host_gui, clap_host_latency, clap_host_log, clap_host_params,
clap_host_state, clap_id, clap_log_severity, clap_param_clear_flags, clap_param_rescan_flags, read_cstr,
};
// ---- host side -----------------------------------------------------------------------------
/// Flags the plugin raises through the host callbacks (any thread).
pub(crate) struct HostState {
pub restart: AtomicBool,
pub process: AtomicBool,
pub callback: AtomicBool,
pub latency_changed: AtomicBool,
pub params_rescan: AtomicBool,
/// `clap_host_state.mark_dirty`.
pub dirty: AtomicBool,
/// `clap_host_gui` requests, serviced by the editor's `idle` on the main thread.
pub gui_closed: AtomicBool,
pub gui_destroyed: AtomicBool,
pub gui_show: AtomicBool,
pub gui_hide: AtomicBool,
/// A size an embedded editor asked for (`width << 32 | height`; 0 = none pending).
pub gui_resize: AtomicU64,
/// Parameter changes the plugin reported (output events: typically edits in its GUI),
/// waiting for the UI. Bounded by `EDIT_CAP`; the audio thread only `try_lock`s it.
pub edits: Mutex<Vec<(u32, f64)>>,
}
impl Default for HostState {
fn default() -> Self {
HostState {
restart: AtomicBool::new(false),
process: AtomicBool::new(false),
callback: AtomicBool::new(false),
latency_changed: AtomicBool::new(false),
params_rescan: AtomicBool::new(false),
dirty: AtomicBool::new(false),
gui_closed: AtomicBool::new(false),
gui_destroyed: AtomicBool::new(false),
gui_show: AtomicBool::new(false),
gui_hide: AtomicBool::new(false),
gui_resize: AtomicU64::new(0),
edits: Mutex::new(Vec::with_capacity(EDIT_CAP)),
}
}
}
/// SAFETY contract: `host` is null or one of our `clap_host` structs, whose `host_data` points
/// to the `HostState` owned by the same `Instance`.
unsafe fn state<'a>(host: *const clap_host) -> Option<&'a HostState> {
if host.is_null() {
return None;
}
// SAFETY: non-null host created by `Bundle::instantiate` (contract above).
let data = unsafe { (*host).host_data } as *const HostState;
// SAFETY: `host_data` points to the boxed `HostState`, alive as long as the instance.
unsafe { data.as_ref() }
}
pub(super) unsafe extern "C" fn host_request_restart(host: *const clap_host) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.restart.store(true, Ordering::Relaxed);
}
}
pub(super) unsafe extern "C" fn host_request_process(host: *const clap_host) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.process.store(true, Ordering::Relaxed);
}
}
pub(super) unsafe extern "C" fn host_request_callback(host: *const clap_host) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.callback.store(true, Ordering::Relaxed);
}
}
unsafe extern "C" fn host_log(_host: *const clap_host, severity: clap_log_severity, msg: *const c_char) {
// SAFETY: CLAP passes a NUL-terminated message (or null, handled).
let m = unsafe { read_cstr(msg) }.unwrap_or_default();
match severity {
CLAP_LOG_ERROR | CLAP_LOG_FATAL => log::error!("clap plugin: {m}"),
CLAP_LOG_WARNING => log::warn!("clap plugin: {m}"),
_ => log::debug!("clap plugin: {m}"),
}
}
unsafe extern "C" fn host_params_rescan(host: *const clap_host, _flags: clap_param_rescan_flags) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.params_rescan.store(true, Ordering::Relaxed);
}
}
unsafe extern "C" fn host_params_clear(_host: *const clap_host, _id: clap_id, _flags: clap_param_clear_flags) {}
unsafe extern "C" fn host_params_request_flush(host: *const clap_host) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.process.store(true, Ordering::Relaxed);
}
}
unsafe extern "C" fn host_latency_changed(host: *const clap_host) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.latency_changed.store(true, Ordering::Relaxed);
}
}
unsafe extern "C" fn host_state_mark_dirty(host: *const clap_host) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.dirty.store(true, Ordering::Relaxed);
}
}
unsafe extern "C" fn host_gui_resize_hints_changed(_host: *const clap_host) {}
unsafe extern "C" fn host_gui_request_resize(host: *const clap_host, width: u32, height: u32) -> bool {
// Floating windows belong to the plugin, which resizes them itself; an embedded editor's host
// window is resized by the editor's `idle` on the main thread.
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) }
&& width > 0
&& height > 0
{
s.gui_resize.store((u64::from(width) << 32) | u64::from(height), Ordering::Relaxed);
}
true
}
unsafe extern "C" fn host_gui_request_show(host: *const clap_host) -> bool {
// SAFETY: the plugin passes back the host pointer we gave it.
match unsafe { state(host) } {
Some(s) => {
s.gui_show.store(true, Ordering::Relaxed);
true
}
None => false,
}
}
unsafe extern "C" fn host_gui_request_hide(host: *const clap_host) -> bool {
// SAFETY: the plugin passes back the host pointer we gave it.
match unsafe { state(host) } {
Some(s) => {
s.gui_hide.store(true, Ordering::Relaxed);
true
}
None => false,
}
}
unsafe extern "C" fn host_gui_closed(host: *const clap_host, was_destroyed: bool) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
if was_destroyed {
s.gui_destroyed.store(true, Ordering::Relaxed);
}
s.gui_closed.store(true, Ordering::Relaxed);
}
}
static HOST_STATE: clap_host_state = clap_host_state { mark_dirty: Some(host_state_mark_dirty) };
static HOST_GUI: clap_host_gui = clap_host_gui {
resize_hints_changed: Some(host_gui_resize_hints_changed),
request_resize: Some(host_gui_request_resize),
request_show: Some(host_gui_request_show),
request_hide: Some(host_gui_request_hide),
closed: Some(host_gui_closed),
};
static HOST_LOG: clap_host_log = clap_host_log { log: Some(host_log) };
static HOST_PARAMS: clap_host_params =
clap_host_params { rescan: Some(host_params_rescan), clear: Some(host_params_clear), request_flush: Some(host_params_request_flush) };
static HOST_LATENCY: clap_host_latency = clap_host_latency { changed: Some(host_latency_changed) };
pub(super) unsafe extern "C" fn host_get_extension(_host: *const clap_host, id: *const c_char) -> *const c_void {
if id.is_null() {
return std::ptr::null();
}
// SAFETY: extension ids are NUL-terminated strings (CLAP contract); non-null checked above.
let id = unsafe { CStr::from_ptr(id) };
if id == CLAP_EXT_LOG {
(&HOST_LOG as *const clap_host_log).cast()
} else if id == CLAP_EXT_PARAMS {
(&HOST_PARAMS as *const clap_host_params).cast()
} else if id == CLAP_EXT_LATENCY {
(&HOST_LATENCY as *const clap_host_latency).cast()
} else if id == CLAP_EXT_STATE {
(&HOST_STATE as *const clap_host_state).cast()
} else if id == CLAP_EXT_GUI {
(&HOST_GUI as *const clap_host_gui).cast()
} else {
// Everything else (thread-check, timers, fd support, …) is unsupported: CLAP allows null.
std::ptr::null()
}
}
+24
View File
@@ -0,0 +1,24 @@
use std::ffi::c_void;
pub const SUPPORTED: bool = false;
pub struct HostWindow;
impl HostWindow {
pub fn new(_title: &str, _w: f64, _h: f64) -> Result<HostWindow, String> {
Err("embedded plugin editors are not hosted on this platform".into())
}
pub fn raw(&self) -> *mut c_void {
std::ptr::null_mut()
}
pub fn parent(&self) -> *mut c_void {
std::ptr::null_mut()
}
pub fn raise(&self) {}
pub fn is_visible(&self) -> bool {
false
}
pub fn close(self) {}
}
pub fn resize(_window: *mut c_void, _w: f64, _h: f64) {}
+165
View File
@@ -0,0 +1,165 @@
use std::ffi::c_void;
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetActiveWindow;
use windows_sys::Win32::UI::WindowsAndMessaging::{
AdjustWindowRectEx, CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, GWL_EXSTYLE, GWL_STYLE,
GetWindowLongPtrW, IDC_ARROW, IsWindow, IsWindowVisible, LoadCursorW, RegisterClassExW, SW_HIDE, SW_SHOW, SWP_NOACTIVATE, SWP_NOMOVE,
SWP_NOZORDER, SetForegroundWindow, SetWindowPos, ShowWindow, WM_CLOSE, WNDCLASSEXW, WS_CAPTION, WS_CLIPCHILDREN, WS_OVERLAPPED, WS_SYSMENU,
};
pub const SUPPORTED: bool = true;
/// Rust names the process's first thread "main"; the app runs its UI (and message loop) there.
fn on_main_thread() -> bool {
std::thread::current().name() == Some("main")
}
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().filter(|&c| c != 0).chain(Some(0)).collect()
}
const STYLE: u32 = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN;
unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if msg == WM_CLOSE {
// SAFETY: `hwnd` is the window this procedure was called for.
unsafe { ShowWindow(hwnd, SW_HIDE) };
return 0;
}
// SAFETY: default handling of a message for the window it was sent to.
unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
}
/// Registers the window class once; the name lives for the whole process.
fn class_name() -> Option<&'static [u16]> {
static CLASS: OnceLock<Option<Vec<u16>>> = OnceLock::new();
CLASS
.get_or_init(|| {
let name = wide("SoundCraftClapEditor");
// SAFETY: null asks for this process's module handle.
let instance = unsafe { GetModuleHandleW(std::ptr::null()) };
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(wndproc),
cbClsExtra: 0,
cbWndExtra: 0,
hInstance: instance,
hIcon: std::ptr::null_mut(),
// SAFETY: a system cursor (null instance + predefined id).
hCursor: unsafe { LoadCursorW(std::ptr::null_mut(), IDC_ARROW) },
hbrBackground: std::ptr::null_mut(),
lpszMenuName: std::ptr::null(),
lpszClassName: name.as_ptr(),
hIconSm: std::ptr::null_mut(),
};
// SAFETY: a fully initialised class description whose strings outlive the call
// (the name is kept for the process lifetime in this `OnceLock`).
(unsafe { RegisterClassExW(&wc) } != 0).then_some(name)
})
.as_deref()
}
/// Outer window size for a client area of `w`×`h`.
fn outer_size(style: u32, ex_style: u32, w: f64, h: f64) -> (i32, i32) {
let mut r = RECT { left: 0, top: 0, right: w as i32, bottom: h as i32 };
// SAFETY: `r` is a valid, writable rect.
unsafe { AdjustWindowRectEx(&mut r, style, 0, ex_style) };
(r.right.saturating_sub(r.left), r.bottom.saturating_sub(r.top))
}
/// Owns the window handle; destroyed in `close` (main thread only).
pub struct HostWindow {
hwnd: HWND,
}
// SAFETY: the handle is only used on the main thread (every method checks); the struct
// merely carries it between threads' locks.
unsafe impl Send for HostWindow {}
impl HostWindow {
fn live(&self) -> Option<HWND> {
// SAFETY: `IsWindow` accepts any value and only reports whether it names a window.
(on_main_thread() && unsafe { IsWindow(self.hwnd) } != 0).then_some(self.hwnd)
}
pub fn new(title: &str, w: f64, h: f64) -> Result<HostWindow, String> {
if !on_main_thread() {
return Err("plugin editors must be opened on the main thread".into());
}
let class = class_name().ok_or_else(|| "cannot register the editor window class".to_string())?;
let title = wide(title);
let (ow, oh) = outer_size(STYLE, 0, w, h);
// SAFETY: main thread; the active window (or null) of this thread's queue.
let owner = unsafe { GetActiveWindow() };
// SAFETY: registered class and NUL-terminated strings that outlive the call; the
// owner is null or a window of this thread.
let hwnd = unsafe {
CreateWindowExW(
0,
class.as_ptr(),
title.as_ptr(),
STYLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
ow,
oh,
owner,
std::ptr::null_mut(),
GetModuleHandleW(std::ptr::null()),
std::ptr::null(),
)
};
if hwnd.is_null() {
return Err("cannot create the editor window".into());
}
Ok(HostWindow { hwnd })
}
/// The window handle (for resizes).
pub fn raw(&self) -> *mut c_void {
self.hwnd
}
/// The `HWND` the plugin creates its child window in.
pub fn parent(&self) -> *mut c_void {
self.live().unwrap_or(std::ptr::null_mut())
}
pub fn raise(&self) {
if let Some(h) = self.live() {
// SAFETY: a live window of this thread.
unsafe {
ShowWindow(h, SW_SHOW);
SetForegroundWindow(h);
}
}
}
pub fn is_visible(&self) -> bool {
// SAFETY: a live window of this thread.
self.live().is_some_and(|h| unsafe { IsWindowVisible(h) } != 0)
}
pub fn close(self) {
if let Some(h) = self.live() {
// SAFETY: our own live window; the plugin's GUI was destroyed first.
unsafe { DestroyWindow(h) };
}
}
}
/// Resizes a host window's client area (`window` from [`HostWindow::raw`]); main thread only.
pub fn resize(window: *mut c_void, w: f64, h: f64) {
// SAFETY: `IsWindow` accepts any value.
if !on_main_thread() || window.is_null() || unsafe { IsWindow(window) } == 0 {
return;
}
// SAFETY: a live window of this thread; style values are read, not changed.
let (style, ex) = unsafe { (GetWindowLongPtrW(window, GWL_STYLE) as u32, GetWindowLongPtrW(window, GWL_EXSTYLE) as u32) };
let (ow, oh) = outer_size(style, ex, w, h);
// SAFETY: as above; only the size changes.
unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, ow, oh, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE) };
}
+409
View File
@@ -0,0 +1,409 @@
use super::bundle::Bundle;
use super::events::{Event, RawEvent, ReadCursor, in_events_get, in_events_size, istream_read, out_events_try_push, transport_event};
use super::gui::GuiLink;
use super::host::HostState;
use super::{
Arc, CLAP_AUDIO_PORT_IS_MAIN, CLAP_PROCESS_ERROR, CStr, ClapError, MAX_PARAMS, MAX_PORT_CHANNELS, MAX_PORTS, Ordering, Transport, c_char, c_void,
clap_audio_buffer, clap_audio_port_info, clap_host, clap_input_events, clap_istream, clap_note_port_info, clap_output_events, clap_param_info,
clap_plugin, clap_plugin_audio_ports, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_process,
read_name,
};
// ---- audio buffers -------------------------------------------------------------------------
/// Planar f32 buffers for every audio port in one direction, with the pointer tables CLAP needs.
/// Allocated in `new`; `process` only rewrites pointers in place (no allocation).
pub(crate) struct PortBuffers {
data: Vec<Vec<Vec<f32>>>,
ptrs: Vec<Vec<*mut f32>>,
bufs: Vec<clap_audio_buffer>,
frames: usize,
}
// SAFETY: the raw pointers only ever point into `data`, which this struct owns; moving the struct
// to another thread moves that ownership with it.
unsafe impl Send for PortBuffers {}
impl PortBuffers {
/// One buffer set per port, `channels[p]` channels each (capped), `frames` long.
pub fn new(channels: &[u32], frames: usize) -> PortBuffers {
let data: Vec<Vec<Vec<f32>>> =
channels.iter().take(MAX_PORTS as usize).map(|&c| (0..c.min(MAX_PORT_CHANNELS)).map(|_| vec![0.0; frames]).collect()).collect();
let ptrs = data.iter().map(|p| vec![std::ptr::null_mut(); p.len()]).collect();
let bufs = data
.iter()
.map(|_| clap_audio_buffer { data32: std::ptr::null_mut(), data64: std::ptr::null_mut(), channel_count: 0, latency: 0, constant_mask: 0 })
.collect();
PortBuffers { data, ptrs, bufs, frames }
}
pub fn channels(&self, port: usize) -> usize {
self.data.get(port).map_or(0, Vec::len)
}
pub fn channel(&self, port: usize, ch: usize) -> Option<&[f32]> {
self.data.get(port).and_then(|p| p.get(ch)).map(Vec::as_slice)
}
pub fn channel_mut(&mut self, port: usize, ch: usize) -> Option<&mut [f32]> {
self.data.get_mut(port).and_then(|p| p.get_mut(ch)).map(Vec::as_mut_slice)
}
/// Points the CLAP tables at the current data (no allocation).
fn refresh(&mut self) {
for ((d, p), b) in self.data.iter_mut().zip(self.ptrs.iter_mut()).zip(self.bufs.iter_mut()) {
for (dc, pc) in d.iter_mut().zip(p.iter_mut()) {
*pc = dc.as_mut_ptr();
}
b.data32 = p.as_mut_ptr();
b.data64 = std::ptr::null_mut();
b.channel_count = u32::try_from(p.len()).unwrap_or(0);
b.constant_mask = 0;
}
}
}
// ---- instances -----------------------------------------------------------------------------
/// What one audio port looks like.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct PortInfo {
pub channels: u32,
pub main: bool,
}
/// One parameter as reported by `clap_plugin_params.get_info`.
#[derive(Debug, Clone)]
pub(crate) struct RawParam {
pub id: u32,
pub name: String,
pub module: String,
pub flags: u32,
pub min: f64,
pub max: f64,
pub default: f64,
}
/// Note dialects of the plugin's first input note port.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct NotePort {
pub supported: u32,
pub preferred: u32,
}
/// A live plugin instance plus the host struct it was created with.
pub(crate) struct Instance {
pub(super) plugin: *const clap_plugin,
pub(super) params: *const clap_plugin_params,
pub(super) ports: *const clap_plugin_audio_ports,
pub(super) latency: *const clap_plugin_latency,
pub(super) notes: *const clap_plugin_note_ports,
pub(super) state_ext: *const clap_plugin_state,
pub(super) gui: Arc<GuiLink>,
pub(super) raw: Vec<RawEvent>,
/// Parameter values the plugin reported through output events in the current `process`.
pub(super) out_params: Vec<(u32, f64)>,
pub(super) active: bool,
pub(super) processing: bool,
pub(super) max_frames: usize,
pub(super) created: bool,
// Field order matters for drop: the plugin is destroyed in `Drop::drop` first; then the host
// struct and its state, then the bundle (which keeps the code mapped) go.
pub(super) _host: Box<clap_host>,
pub(super) state: Arc<HostState>,
pub(super) _bundle: Arc<Bundle>,
}
// SAFETY: an `Instance` is used through `&mut self` only, so calls into the plugin are always
// serialised. CLAP lets audio-thread functions run on any one thread at a time; SoundCraft creates
// instances on the engine/UI thread and then moves them into the mix engine (documented
// limitation: some main-thread calls such as `activate` may then happen on the audio thread).
unsafe impl Send for Instance {}
impl Instance {
fn vt(&self) -> clap_plugin {
// SAFETY: `plugin` is non-null (checked at creation) and alive until `Drop`.
unsafe { *self.plugin }
}
pub(super) fn extension<T>(&self, id: &CStr) -> *const T {
match self.vt().get_extension {
// SAFETY: valid instance; `id` is a static NUL-terminated string.
Some(f) => unsafe { f(self.plugin, id.as_ptr()) }.cast::<T>(),
None => std::ptr::null(),
}
}
pub fn state(&self) -> &HostState {
&self.state
}
/// The editor link shared with editor handles (`None` when the plugin has no `clap.gui`).
pub fn gui(&self) -> Option<Arc<GuiLink>> {
self.gui.available().then(|| Arc::clone(&self.gui))
}
/// The same link, for state handles (`None` when the plugin has no `clap.state`).
pub fn state_link(&self) -> Option<Arc<GuiLink>> {
(!self.state_ext.is_null()).then(|| Arc::clone(&self.gui))
}
/// `clap_plugin_state.load` from memory. `false` without the extension or when refused.
pub fn load_state(&self, data: &[u8]) -> bool {
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
let Some(load) = (unsafe { self.state_ext.as_ref() }).and_then(|s| s.load) else { return false };
let mut cursor = ReadCursor { data, pos: 0 };
let stream = clap_istream { ctx: (&mut cursor as *mut ReadCursor<'_>).cast::<c_void>(), read: Some(istream_read) };
// SAFETY: valid instance; the stream and its cursor live on this frame for the call.
unsafe { load(self.plugin, &stream) }
}
/// Runs `on_main_thread` if the plugin asked for a callback.
pub fn service_callback(&mut self) {
if self.state.callback.swap(false, Ordering::Relaxed)
&& let Some(f) = self.vt().on_main_thread
{
// SAFETY: valid instance; the plugin asked for this call via `request_callback`.
unsafe { f(self.plugin) };
}
}
pub fn params(&self) -> Vec<RawParam> {
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
let Some(p) = (unsafe { self.params.as_ref() }) else { return Vec::new() };
let (Some(count), Some(get_info)) = (p.count, p.get_info) else { return Vec::new() };
// SAFETY: vtable call with the instance it belongs to.
let n = unsafe { count(self.plugin) }.min(MAX_PARAMS);
let mut out = Vec::new();
for i in 0..n {
// SAFETY: `clap_param_info` is plain data (integers, floats, char arrays, a nullable
// pointer); all-zero is a valid value.
let mut info: clap_param_info = unsafe { std::mem::zeroed() };
// SAFETY: index below the count; `info` is a valid, writable struct.
if !unsafe { get_info(self.plugin, i, &mut info) } {
continue;
}
let (min, max) = (info.min_value, info.max_value);
if !(min.is_finite() && max.is_finite()) || max < min {
continue;
}
let default = if info.default_value.is_finite() { info.default_value.clamp(min, max) } else { min };
out.push(RawParam { id: info.id, name: read_name(&info.name), module: read_name(&info.module), flags: info.flags, min, max, default });
}
out
}
pub fn param_value(&self, id: u32) -> Option<f64> {
// SAFETY: see `params`.
let p = unsafe { self.params.as_ref() }?;
let get = p.get_value?;
let mut v = 0.0f64;
// SAFETY: valid instance; `v` is writable.
(unsafe { get(self.plugin, id, &mut v) } && v.is_finite()).then_some(v)
}
pub fn value_text(&self, id: u32, value: f64) -> Option<String> {
// SAFETY: see `params`.
let p = unsafe { self.params.as_ref() }?;
let f = p.value_to_text?;
let mut buf = [0 as c_char; 128];
// SAFETY: valid instance; we pass the buffer's true capacity.
let ok = unsafe { f(self.plugin, id, value, buf.as_mut_ptr(), buf.len() as u32) };
ok.then(|| read_name(&buf)).filter(|s| !s.is_empty())
}
/// The plugin's audio ports in one direction. A layout beyond `MAX_PORTS` ports or
/// `MAX_PORT_CHANNELS` channels per port is an error: we never hand a plugin fewer ports or
/// channels than it advertised.
pub fn audio_ports(&self, is_input: bool) -> Result<Vec<PortInfo>, ClapError> {
// SAFETY: see `params`.
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Ok(Vec::new()) };
let (Some(count), Some(get)) = (p.count, p.get) else { return Ok(Vec::new()) };
let dir = if is_input { "input" } else { "output" };
// SAFETY: vtable call with its instance.
let n = unsafe { count(self.plugin, is_input) };
if n > MAX_PORTS {
return Err(ClapError::Process(format!("{n} audio {dir} ports (at most {MAX_PORTS} supported)")));
}
let mut out = Vec::new();
for i in 0..n {
// SAFETY: plain data; all-zero (null port_type pointer) is valid.
let mut info: clap_audio_port_info = unsafe { std::mem::zeroed() };
// SAFETY: index below the count; `info` is writable.
if unsafe { get(self.plugin, i, is_input, &mut info) } {
if info.channel_count > MAX_PORT_CHANNELS {
return Err(ClapError::Process(format!(
"audio {dir} port {i} has {} channels (at most {MAX_PORT_CHANNELS} supported)",
info.channel_count
)));
}
out.push(PortInfo { channels: info.channel_count, main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
} else {
// Keep port indices aligned: an unreadable port still needs a (silent) buffer slot.
out.push(PortInfo { channels: 0, main: false });
}
}
Ok(out)
}
pub fn note_port(&self) -> Option<NotePort> {
// SAFETY: see `params`.
let p = unsafe { self.notes.as_ref() }?;
let (count, get) = (p.count?, p.get?);
// SAFETY: vtable call with its instance.
if unsafe { count(self.plugin, true) } == 0 {
return None;
}
// SAFETY: plain data; all-zero is valid.
let mut info: clap_note_port_info = unsafe { std::mem::zeroed() };
// SAFETY: index 0 is below the non-zero count; `info` is writable.
unsafe { get(self.plugin, 0, true, &mut info) }.then_some(NotePort { supported: info.supported_dialects, preferred: info.preferred_dialect })
}
pub fn latency(&self) -> u32 {
// SAFETY: see `params`.
match unsafe { self.latency.as_ref() }.and_then(|l| l.get) {
// SAFETY: valid instance.
Some(f) => unsafe { f(self.plugin) },
None => 0,
}
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn activate(&mut self, sample_rate: f64, min_frames: u32, max_frames: u32) -> Result<(), ClapError> {
self.deactivate();
let f = self.vt().activate.ok_or_else(|| ClapError::Process("plugin has no activate".into()))?;
// SAFETY: valid, inactive instance; arguments are sane (checked by the caller).
if !unsafe { f(self.plugin, sample_rate, min_frames, max_frames) } {
return Err(ClapError::Process("activate failed".into()));
}
self.active = true;
self.max_frames = max_frames as usize;
Ok(())
}
pub fn deactivate(&mut self) {
self.stop_processing();
if self.active {
if let Some(f) = self.vt().deactivate {
// SAFETY: valid, active instance.
unsafe { f(self.plugin) };
}
self.active = false;
}
}
fn stop_processing(&mut self) {
if self.processing {
if let Some(f) = self.vt().stop_processing {
// SAFETY: valid instance in the processing state.
unsafe { f(self.plugin) };
}
self.processing = false;
}
}
pub fn reset(&mut self) {
if self.active
&& let Some(f) = self.vt().reset
{
// SAFETY: valid, active instance.
unsafe { f(self.plugin) };
}
}
/// Runs one `clap_process` call over `frames` frames. Events must be sorted by time; their
/// times are made relative to `offset`. Returns the CLAP process status.
pub fn process(&mut self, input: ProcessInput<'_>) -> Result<i32, ClapError> {
let ProcessInput { frames, steady_time, inputs, outputs, events, offset, transport } = input;
if !self.active {
return Err(ClapError::Process("not active".into()));
}
if frames == 0 || frames > self.max_frames || frames > inputs.frames || frames > outputs.frames {
return Err(ClapError::Process("block larger than the activated size".into()));
}
if !self.processing {
let ok = match self.vt().start_processing {
// SAFETY: valid, active instance.
Some(f) => unsafe { f(self.plugin) },
None => true,
};
if !ok {
return Err(ClapError::Process("start_processing failed".into()));
}
self.processing = true;
}
let process = self.vt().process.ok_or_else(|| ClapError::Process("plugin has no process".into()))?;
self.raw.clear();
for e in events.iter().take(self.raw.capacity()) {
self.raw.push(RawEvent::from_event(e, offset));
}
inputs.refresh();
outputs.refresh();
let in_events = clap_input_events {
ctx: (&self.raw as *const Vec<RawEvent>).cast_mut().cast::<c_void>(),
size: Some(in_events_size),
get: Some(in_events_get),
};
self.out_params.clear();
let out_events =
clap_output_events { ctx: (&mut self.out_params as *mut Vec<(u32, f64)>).cast::<c_void>(), try_push: Some(out_events_try_push) };
let transport = transport.map(transport_event);
let p = clap_process {
steady_time,
frames_count: u32::try_from(frames).unwrap_or(0),
transport: transport.as_ref().map_or(std::ptr::null(), std::ptr::from_ref),
audio_inputs: inputs.bufs.as_ptr(),
audio_outputs: outputs.bufs.as_mut_ptr(),
audio_inputs_count: u32::try_from(inputs.bufs.len()).unwrap_or(0),
audio_outputs_count: u32::try_from(outputs.bufs.len()).unwrap_or(0),
in_events: &in_events,
out_events: &out_events,
};
// SAFETY: valid, processing instance. Every buffer pointer refers to `frames` (or more)
// valid f32s owned by `inputs`/`outputs`, which are borrowed for the whole call; the event
// lists and their contexts and the transport live on this stack frame / in `self.raw`,
// untouched until the call returns.
let status = unsafe { process(self.plugin, &p) };
if !self.out_params.is_empty()
&& let Ok(mut edits) = self.state.edits.try_lock()
{
// Never blocks or grows: a busy UI or a full queue drops the edits.
for &e in &self.out_params {
if edits.len() < edits.capacity() {
edits.push(e);
}
}
}
self.out_params.clear();
if status == CLAP_PROCESS_ERROR { Err(ClapError::Process("plugin reported a processing error".into())) } else { Ok(status) }
}
}
pub(crate) struct ProcessInput<'a> {
pub frames: usize,
pub steady_time: i64,
pub inputs: &'a mut PortBuffers,
pub outputs: &'a mut PortBuffers,
pub events: &'a [Event],
pub offset: u32,
pub transport: Option<&'a Transport>,
}
impl Drop for Instance {
fn drop(&mut self) {
// Editors first (CLAP: destroy the GUI before the plugin), and no handle may call in after.
self.gui.kill();
self.deactivate();
if self.created
&& let Some(f) = self.vt().destroy
{
// SAFETY: valid instance, deactivated above; destroyed exactly once.
unsafe { f(self.plugin) };
}
self.created = false;
}
}
+41 -21
View File
@@ -1,6 +1,6 @@
//! `ClapPlugin`: a loaded CLAP instance behind the safe `soundcraft_dsp::Plugin` trait.
use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers};
use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers, ProcessInput};
use clap_sys::ext::note_ports::{CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI};
use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport};
use std::sync::Arc;
@@ -102,6 +102,35 @@ fn note_mode(p: Option<NotePort>) -> NoteMode {
}
}
/// Copy `n` frames from `start` of the host buffers into the plugin's input `port` (mono sources
/// fill every port channel).
fn feed_input(inputs: &mut PortBuffers, port: usize, io: &[Vec<f32>], ch: usize, (start, n): (usize, usize)) {
for c in 0..inputs.channels(port) {
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
if let (Some(src), Some(dst)) = (src, inputs.channel_mut(port, c).and_then(|d| d.get_mut(..n))) {
dst.copy_from_slice(src);
}
}
}
/// Copy `n` frames of the plugin's output `port` into the host buffers from `start`; a mono host
/// buffer takes the average of a stereo port.
fn read_output(outputs: &PortBuffers, port: usize, io: &mut [Vec<f32>], ch: usize, (start, n): (usize, usize)) {
let oc = outputs.channels(port);
for (c, dst) in io.iter_mut().take(ch).enumerate() {
let Some(dst) = dst.get_mut(start..start + n) else { continue };
if ch == 1 && oc >= 2 {
if let (Some(l), Some(r)) = (outputs.channel(port, 0), outputs.channel(port, 1)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = outputs.channel(port, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
dst.copy_from_slice(src);
}
}
}
impl Plugin for ClapPlugin {
fn info(&self) -> &'static PluginInfo {
self.info
@@ -197,34 +226,25 @@ impl Plugin for ClapPlugin {
let end = u32::try_from(start + n).unwrap_or(u32::MAX);
// Feed the main input port (mono sources fill every port channel).
if let Some(p) = self.in_main {
for c in 0..self.inputs.channels(p) {
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(p, c).and_then(|d| d.get_mut(..n))) {
dst.copy_from_slice(src);
}
}
feed_input(&mut self.inputs, p, io, ch, (start, n));
}
let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len());
let offset = u32::try_from(start).unwrap_or(u32::MAX);
let events = self.queue.get(..k).unwrap_or(&[]);
let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset, self.transport.as_ref());
let r = self.inst.process(ProcessInput {
frames: n,
steady_time: self.steady,
inputs: &mut self.inputs,
outputs: &mut self.outputs,
events,
offset,
transport: self.transport.as_ref(),
});
self.queue.drain(..k);
self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0));
match (r, self.out_main) {
(Ok(_), Some(p)) => {
let oc = self.outputs.channels(p);
for (c, dst) in io.iter_mut().take(ch).enumerate() {
let Some(dst) = dst.get_mut(start..start + n) else { continue };
if ch == 1 && oc >= 2 {
if let (Some(l), Some(r)) = (self.outputs.channel(p, 0), self.outputs.channel(p, 1)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = self.outputs.channel(p, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
dst.copy_from_slice(src);
}
}
read_output(&self.outputs, p, io, ch, (start, n));
}
(Err(e), _) => log::debug!("{}: {e}", self.info.id),
_ => {}
+1 -1
View File
@@ -9,7 +9,7 @@ use std::sync::OnceLock;
pub fn fixture_dir() -> &'static Path {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/Cargo.toml");
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../clap-host/tests/fixture/Cargo.toml");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("clap-fixture-target");
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let st = Command::new(cargo)
@@ -15,6 +15,11 @@ fn block(p: &mut Box<dyn Plugin>) -> Vec<Vec<f32>> {
}
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("editor_main_thread: test");
return;
}
common::fixture_dir();
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
p.prepare(48_000.0, 64, 2);
+1 -1
View File
@@ -1,4 +1,4 @@
# A tiny CLAP plugin used only by soundcraft-clap-host's integration tests. Not part of the
# A tiny CLAP plugin used only by SoundCraft's integration tests. Not part of the
# SoundCraft workspace (the empty [workspace] table makes it its own root); the test builds it
# with `cargo build` and loads the resulting library. Never shipped.
[package]
+57 -47
View File
@@ -189,6 +189,61 @@ fn apply(f: &mut Fx, h: &clap_event_header) {
}
}
/// Fill every output channel with 1.0 when all ports have the wide layout, else -1.0.
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
let fits = |bufs: *const clap_audio_buffer, n: u32| {
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
};
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
if pr.audio_outputs_count > 0 {
let out = unsafe { &*pr.audio_outputs };
for c in 0..out.channel_count as usize {
let o = unsafe { *out.data32.add(c) };
for i in 0..n {
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
}
}
}
}
/// Push the host-visible parameter edit and an ignorable note event onto the output queue.
unsafe fn push_gui_edit(pr: &clap_process) {
let e = clap_event_param_value {
header: clap_event_header {
size: std::mem::size_of::<clap_event_param_value>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_PARAM_VALUE,
flags: 0,
},
param_id: 7,
cookie: std::ptr::null_mut(),
note_id: -1,
port_index: -1,
channel: -1,
key: -1,
value: 0.25,
};
let out = unsafe { &*pr.out_events };
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) });
// Other event types are accepted and ignored by the host.
let note = clap_event_note {
header: clap_event_header {
size: std::mem::size_of::<clap_event_note>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_NOTE_END,
flags: 0,
},
note_id: -1,
port_index: 0,
channel: 0,
key: 60,
velocity: 0.0,
};
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &note.header) });
}
unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> clap_process_status {
let f = unsafe { fx(p) };
let pr = unsafe { &*pr };
@@ -197,19 +252,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
let ev = unsafe { &*pr.in_events };
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
if let Some((ports, channels)) = f.wide {
let fits = |bufs: *const clap_audio_buffer, n: u32| {
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
};
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
if pr.audio_outputs_count > 0 {
let out = unsafe { &*pr.audio_outputs };
for c in 0..out.channel_count as usize {
let o = unsafe { *out.data32.add(c) };
for i in 0..n {
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
}
}
}
unsafe { fill_wide(pr, ports, channels, n) };
return CLAP_PROCESS_CONTINUE;
}
if pr.audio_outputs_count != 1 {
@@ -230,40 +273,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
if f.gui_edit_pending {
f.gui_edit_pending = false;
f.gain = 0.25;
let e = clap_event_param_value {
header: clap_event_header {
size: std::mem::size_of::<clap_event_param_value>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_PARAM_VALUE,
flags: 0,
},
param_id: 7,
cookie: std::ptr::null_mut(),
note_id: -1,
port_index: -1,
channel: -1,
key: -1,
value: 0.25,
};
let out = unsafe { &*pr.out_events };
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) });
// Other event types are accepted and ignored by the host.
let note = clap_event_note {
header: clap_event_header {
size: std::mem::size_of::<clap_event_note>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_NOTE_END,
flags: 0,
},
note_id: -1,
port_index: 0,
channel: 0,
key: 60,
velocity: 0.0,
};
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &note.header) });
unsafe { push_gui_edit(pr) };
}
let mut next = 0u32;
let mut last_time = 0u32;
+29 -15
View File
@@ -150,8 +150,9 @@ pub struct LoudnessMeter {
sub_filled: usize,
hist_count: Vec<u64>,
hist_energy: Vec<f64>,
tp_coefs: [[f32; TP_TAPS]; TP_PHASES],
tp_hist: Vec<[f32; TP_TAPS]>,
tp_coefs: [[f32; TP_PHASES]; TP_TAPS],
tp_hist: Vec<[f32; 2 * TP_TAPS]>,
tp_pos: Vec<usize>,
true_peak: f32,
max_momentary: f32,
}
@@ -197,7 +198,7 @@ impl LoudnessMeter {
let weights = if channels == 6 { vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41] } else { vec![1.0; channels] };
let [c1, c2] = k_weighting(sr);
// Interpolation filter: windowed sinc at the original Nyquist, split into 4 phases.
let mut tp_coefs = [[0.0f32; TP_TAPS]; TP_PHASES];
let mut tp_coefs = [[0.0f32; TP_PHASES]; TP_TAPS];
let total = TP_TAPS * TP_PHASES;
let center = (total as f64 - 1.0) / 2.0;
for i in 0..total {
@@ -205,14 +206,14 @@ impl LoudnessMeter {
let sinc = if t.abs() < 1e-12 { 1.0 } else { (std::f64::consts::PI * t).sin() / (std::f64::consts::PI * t) };
let x = i as f64 / (total as f64 - 1.0);
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * x).cos() + 0.08 * (4.0 * std::f64::consts::PI * x).cos();
if let Some(slot) = tp_coefs.get_mut(i % TP_PHASES).and_then(|ph| ph.get_mut(i / TP_PHASES)) {
if let Some(slot) = tp_coefs.get_mut(i / TP_PHASES).and_then(|tap| tap.get_mut(i % TP_PHASES)) {
*slot = (sinc * w) as f32;
}
}
for ph in &mut tp_coefs {
let s: f32 = ph.iter().sum();
for p in 0..TP_PHASES {
let s: f32 = tp_coefs.iter().filter_map(|tap| tap.get(p)).sum();
if s.abs() > 1e-6 {
ph.iter_mut().for_each(|c| *c /= s);
tp_coefs.iter_mut().filter_map(|tap| tap.get_mut(p)).for_each(|c| *c /= s);
}
}
Self {
@@ -228,7 +229,8 @@ impl LoudnessMeter {
hist_count: vec![0; HIST_BINS],
hist_energy: vec![0.0; HIST_BINS],
tp_coefs,
tp_hist: vec![[0.0; TP_TAPS]; channels],
tp_hist: vec![[0.0; 2 * TP_TAPS]; channels],
tp_pos: vec![0; channels],
true_peak: 0.0,
max_momentary: MIN_DB,
}
@@ -257,7 +259,8 @@ impl LoudnessMeter {
self.sub_filled = 0;
self.hist_count.iter_mut().for_each(|v| *v = 0);
self.hist_energy.iter_mut().for_each(|v| *v = 0.0);
self.tp_hist.iter_mut().for_each(|h| *h = [0.0; TP_TAPS]);
self.tp_hist.iter_mut().for_each(|h| *h = [0.0; 2 * TP_TAPS]);
self.tp_pos.iter_mut().for_each(|p| *p = 0);
self.true_peak = 0.0;
self.max_momentary = MIN_DB;
}
@@ -285,13 +288,24 @@ impl LoudnessMeter {
}
fn true_peak_sample(&mut self, c: usize, x: f32) {
let Some(h) = self.tp_hist.get_mut(c) else { return };
h.copy_within(0..TP_TAPS - 1, 1);
h[0] = x;
let (Some(h), Some(pos)) = (self.tp_hist.get_mut(c), self.tp_pos.get_mut(c)) else { return };
*pos = (*pos + TP_TAPS - 1) % TP_TAPS;
let p = *pos;
for slot in [p, p + TP_TAPS] {
if let Some(v) = h.get_mut(slot) {
*v = x;
}
}
let Some(w) = h.get(p..p + TP_TAPS).and_then(|w| <&[f32; TP_TAPS]>::try_from(w).ok()) else { return };
let mut y = [-0.0f32; TP_PHASES];
for (cs, s) in self.tp_coefs.iter().zip(w) {
for (acc, k) in y.iter_mut().zip(cs) {
*acc += k * s;
}
}
let mut peak = x.abs();
for ph in &self.tp_coefs {
let y: f32 = ph.iter().zip(h.iter()).map(|(a, b)| a * b).sum();
peak = peak.max(y.abs());
for v in y {
peak = peak.max(v.abs());
}
if peak > self.true_peak {
self.true_peak = peak;
+274 -65
View File
@@ -54,7 +54,24 @@ fn max_len(ch: &[Vec<f32>]) -> usize {
/// The largest finite sample magnitude in any channel.
pub fn peak(ch: &[Vec<f32>]) -> f32 {
ch.iter().flatten().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs()))
ch.iter().map(|c| channel_peak(c)).fold(0.0f32, f32::max)
}
/// Largest finite absolute sample of one channel (0 for none).
pub fn channel_peak(x: &[f32]) -> f32 {
let mut lanes = [0.0f32; 8];
let (chunks, rest) = x.as_chunks::<8>();
for c in chunks {
for (m, v) in lanes.iter_mut().zip(c) {
let a = v.abs();
*m = if a < f32::INFINITY && a > *m { a } else { *m };
}
}
for (m, v) in lanes.iter_mut().zip(rest) {
let a = v.abs();
*m = if a < f32::INFINITY && a > *m { a } else { *m };
}
lanes.into_iter().fold(0.0f32, f32::max)
}
/// Peak-normalizes to `target_db` dBFS, either all channels together or each on its own.
@@ -226,16 +243,95 @@ fn resample_ratio(ch: &[Vec<f32>], ratio: f64, out_len: usize) -> Vec<Vec<f32>>
ch.iter().map(|c| resample_channel(c, ratio, out_len, &table)).collect()
}
/// Most kernel weights a [`Polyphase`] bank may hold (16 MiB of f32).
const MAX_POLYPHASE_WEIGHTS: usize = 1 << 22;
/// The windowed-sinc kernel of [`resample_channel`] precomputed for every phase of a rational
/// rate change `from` → `to` (reduced to `step` / `phases`). Output sample `n` reads input time
/// `n * step / phases`: whole part `i`, phase `p`. Its taps are input samples `i + first ..`, one
/// weight each, so every output is one dot product over contiguous memory instead of a table
/// lookup and interpolation per tap. The weights are the ones `resample_channel` computes, taps
/// beyond the kernel's half width weigh 0, and taps are summed in the same order.
struct Polyphase {
phases: u64,
step: u64,
/// Offset of the first tap from `i`.
first: i64,
taps: usize,
/// `phases * taps` weights, phase-major.
weights: Vec<f32>,
}
impl Polyphase {
/// `None` when the bank would be too large (rates with no small common ratio); the caller
/// then falls back to [`resample_channel`].
fn new(from: u32, to: u32, table: &SincTable) -> Option<Polyphase> {
let g = gcd(from, to);
let (step, phases) = (u64::from(from / g), u64::from(to / g));
let hw = table.half_width;
let first = (-hw).floor() as i64;
let last = hw.ceil() as i64 + 1;
let taps = usize::try_from(last - first + 1).ok()?;
if usize::try_from(phases).ok()?.checked_mul(taps)? > MAX_POLYPHASE_WEIGHTS {
return None;
}
let mut weights = Vec::with_capacity(taps * phases as usize);
for p in 0..phases {
let frac = p as f64 / phases as f64;
for k in first..first + taps as i64 {
let d = frac - k as f64;
weights.push(if d.abs() <= hw { table.at(d) } else { 0.0 });
}
}
Some(Polyphase { phases, step, first, taps, weights })
}
fn run(&self, x: &[f32], out_len: usize) -> Vec<f32> {
let len = x.len() as i64;
let mut out = Vec::with_capacity(out_len);
for n in 0..out_len as u64 {
let Some(pos) = n.checked_mul(self.step) else { break };
let (i, p) = ((pos / self.phases) as i64, (pos % self.phases) as usize);
let w = self.weights.get(p * self.taps..(p + 1) * self.taps).unwrap_or_default();
let start = i + self.first;
let acc = match usize::try_from(start).ok().and_then(|s| x.get(s..s + self.taps)) {
// Inside the signal: a plain dot product.
Some(xs) => xs.iter().zip(w).fold(0.0f32, |a, (v, k)| a + v * k),
// At the edges, taps outside the signal are skipped, as `resample_channel` does.
None => (0..self.taps as i64)
.filter(|k| (0..len).contains(&(start + k)))
.fold(0.0f32, |a, k| a + x.get((start + k) as usize).copied().unwrap_or(0.0) * w.get(k as usize).copied().unwrap_or(0.0)),
};
out.push(acc);
}
out.resize(out_len, 0.0);
out
}
}
fn gcd(mut a: u32, mut b: u32) -> u32 {
while b != 0 {
(a, b) = (b, a % b);
}
a.max(1)
}
/// High-quality windowed-sinc sample-rate conversion from `from` Hz to `to` Hz.
pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
if from == 0 || to == 0 || from == to {
return ch.to_vec();
}
let ratio = f64::from(to) / f64::from(from);
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
let table = SincTable::new(cutoff);
let bank = Polyphase::new(from, to, &table);
ch.iter()
.map(|c| {
let out_len = ((c.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
resample_ratio(std::slice::from_ref(c), ratio, out_len).into_iter().next().unwrap_or_default()
match &bank {
Some(b) => b.run(c, out_len),
None => resample_channel(c, ratio, out_len, &table),
}
})
.collect()
}
@@ -243,6 +339,77 @@ pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
// ---------------------------------------------------------------------------------------------
// Time stretch (WSOLA) and pitch shift
/// Start position in the guide signal whose `hop` samples best match the natural continuation at
/// `natural`, searched within `tol` of `nominal` (coarse stride 4, then refined +-3).
fn best_start(g: &impl Fn(i64) -> f32, nominal: i64, natural: i64, tol: i64, hop: usize) -> i64 {
// Similarity of candidate start `p` with the natural continuation (stride 2).
let score = |p: i64| -> f32 {
let (mut xy, mut yy) = (0.0f32, 1e-9f32);
let mut i = 0i64;
while i < hop as i64 {
let a = g(natural + i);
let b = g(p + i);
xy += a * b;
yy += b * b;
i += 2;
}
xy / yy.sqrt()
};
let lo = (nominal - tol).max(0);
let hi = nominal + tol;
let mut best = nominal.max(0);
let mut best_s = f32::NEG_INFINITY;
let mut p = lo;
while p <= hi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
p += 4;
}
let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi));
for p in rlo..=rhi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
}
best
}
/// Add one windowed frame of every channel, read from `at.0`, into `out` at `at.1`, and the window
/// into `wsum`.
fn overlap_add(ch: &[Vec<f32>], out: &mut [Vec<f32>], wsum: &mut [f32], window: &[f32], at: (usize, usize)) {
let (in_pos, out_pos) = at;
for (c, o) in ch.iter().zip(out.iter_mut()) {
for (i, w) in window.iter().enumerate() {
let v = c.get(in_pos + i).copied().unwrap_or(0.0);
if let Some(d) = o.get_mut(out_pos + i) {
*d += v * w;
}
}
}
for (i, w) in window.iter().enumerate() {
if let Some(d) = wsum.get_mut(out_pos + i) {
*d += w;
}
}
}
/// Divide out the accumulated window weights and cut each channel to `out_len`.
fn normalise(out: &mut [Vec<f32>], wsum: &[f32], out_len: usize) {
for o in out.iter_mut() {
for (s, w) in o.iter_mut().zip(wsum) {
if *w > 1e-3 {
*s /= w;
}
}
o.truncate(out_len);
}
}
/// Pitch-preserving time stretch by WSOLA (waveform-similarity overlap-add).
/// `ratio` = output length / input length, clamped to 0.1 ..= 10. All channels share the same
/// segment choices so the stereo image stays coherent.
@@ -275,72 +442,12 @@ pub fn time_stretch(ch: &[Vec<f32>], ratio: f64, sample_rate: f32) -> Vec<Vec<f3
if out_pos >= out_len {
break;
}
let in_pos = if k == 0 {
0
} else {
let nominal = (out_pos as f64 / ratio).round() as i64;
let natural = prev_in + hop as i64;
// Similarity of candidate start `p` with the natural continuation (stride 2).
let score = |p: i64| -> f32 {
let (mut xy, mut yy) = (0.0f32, 1e-9f32);
let mut i = 0i64;
while i < hop as i64 {
let a = g(natural + i);
let b = g(p + i);
xy += a * b;
yy += b * b;
i += 2;
}
xy / yy.sqrt()
};
let lo = (nominal - tol).max(0);
let hi = nominal + tol;
let mut best = nominal.max(0);
let mut best_s = f32::NEG_INFINITY;
let mut p = lo;
while p <= hi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
p += 4;
}
let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi));
for p in rlo..=rhi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
}
best
};
for (c, o) in ch.iter().zip(out.iter_mut()) {
for (i, w) in window.iter().enumerate() {
let src = in_pos as usize + i;
let v = c.get(src).copied().unwrap_or(0.0);
if let Some(d) = o.get_mut(out_pos + i) {
*d += v * w;
}
}
}
for (i, w) in window.iter().enumerate() {
if let Some(d) = wsum.get_mut(out_pos + i) {
*d += w;
}
}
let in_pos = if k == 0 { 0 } else { best_start(&g, (out_pos as f64 / ratio).round() as i64, prev_in + hop as i64, tol, hop) };
overlap_add(ch, &mut out, &mut wsum, &window, (in_pos as usize, out_pos));
prev_in = in_pos;
k += 1;
}
for o in out.iter_mut() {
for (s, w) in o.iter_mut().zip(&wsum) {
if *w > 1e-3 {
*s /= w;
}
}
o.truncate(out_len);
}
normalise(&mut out, &wsum, out_len);
out
}
@@ -373,6 +480,78 @@ pub fn pitch_shift(ch: &[Vec<f32>], semitones: f32, sample_rate: f32) -> Vec<Vec
out
}
/// Which way [`speed_ramp`] ramps the playback speed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpeedRampChange {
/// From normal speed down to a complete stop.
SlowDown,
/// From a complete stop up to normal speed.
SpeedUp,
}
/// How much of the input [`speed_ramp`] plays, and how long the result is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpeedRampSelection {
/// The result is as long as the input. Two thirds of the input are played: the first two
/// thirds for Slow Down, the last two thirds for Speed Up, so each joins its neighbour
/// without a jump.
FitTo,
/// All of the input is played, and the result is 150 % of its length.
Extend,
}
/// Plays `ch` with the speed ramping between normal speed and a complete stop, so the pitch follows
/// the speed. The speed is `1 - u²` for Slow Down and `1 - (1 - u)²` for Speed Up, where `u`
/// runs from 0 to 1 over the result; both curves average 2/3 of normal speed. With `fades`, the
/// gain follows the same curve: a fade-out for Slow Down and a fade-in for Speed Up. Every channel
/// is read at the same positions, and a channel shorter than the others is silent past its end.
pub fn speed_ramp(ch: &[Vec<f32>], change: SpeedRampChange, selection: SpeedRampSelection, fades: bool) -> Vec<Vec<f32>> {
let len = max_len(ch) as f64;
let (total, first, played) = match selection {
SpeedRampSelection::Extend => ((len * 1.5).round(), 0.0, len),
SpeedRampSelection::FitTo => {
let played = len * 2.0 / 3.0;
(len, if change == SpeedRampChange::SlowDown { 0.0 } else { len - played }, played)
}
};
let out_len = (total as usize).min(MAX_OFFLINE_SAMPLES);
let table = SincTable::new(0.97);
let hw = table.half_width;
// The fraction of the played input reached at `u`, and the speed there (0 to 1).
let at = |u: f64| -> (f64, f64) {
match change {
SpeedRampChange::SlowDown => ((3.0 * u - u * u * u) / 2.0, 1.0 - u * u),
SpeedRampChange::SpeedUp => {
let r = 1.0 - u;
(1.0 - (3.0 * r - r * r * r) / 2.0, 1.0 - r * r)
}
}
};
// The interpolation reads the first or last sample of the input for taps before or after it.
let last = (len as i64 - 1).max(0);
ch.iter()
.map(|x| {
(0..out_len)
.map(|n| {
let (pos, speed) = at(n as f64 / total);
let t = first + played * pos;
let lo = (t - hw).ceil() as i64;
let hi = (t + hw).floor() as i64;
let mut acc = 0.0f32;
let mut j = lo;
while j <= hi {
if let Some(&v) = x.get(j.clamp(0, last) as usize) {
acc += v * table.at(t - j as f64);
}
j += 1;
}
if fades { acc * speed as f32 } else { acc }
})
.collect()
})
.collect()
}
// ---------------------------------------------------------------------------------------------
// Generators and analysis
@@ -662,6 +841,36 @@ pub fn peak_db(ch: &[Vec<f32>]) -> f32 {
mod tests {
use super::*;
/// The polyphase bank gives what the per-tap kernel gives (it only moves where the weights
/// are computed), at common rate pairs, both directions, and at the signal's edges.
#[test]
fn polyphase_resample_matches_the_per_tap_kernel() {
let x: Vec<f32> = (0..3_000).map(|i| (i as f32 * 0.071).sin() * 0.6 + (i as f32 * 0.53).sin() * 0.3).collect();
let pairs = [(44_100, 48_000), (48_000, 44_100), (48_000, 96_000), (96_000, 48_000), (22_050, 48_000), (8_000, 44_100), (48_000, 22_050)];
for (from, to) in pairs {
let ratio = f64::from(to) / f64::from(from);
let out_len = ((x.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
let table = SincTable::new(cutoff);
let reference = resample_channel(&x, ratio, out_len, &table);
let bank = Polyphase::new(from, to, &table).expect("small common ratio");
let fast = bank.run(&x, out_len);
assert_eq!(fast.len(), reference.len(), "{from}->{to}");
let worst = fast.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst < 1e-5, "{from}->{to}: worst difference {worst}");
assert_eq!(resample(std::slice::from_ref(&x), from, to)[0], fast, "resample uses the bank");
}
// Signals shorter than the kernel are all edge.
let short = [0.5f32, -0.25, 0.75];
let table = SincTable::new(0.97);
let reference = resample_channel(&short, 48_000.0 / 44_100.0, 3, &table);
let fast = Polyphase::new(44_100, 48_000, &table).expect("bank").run(&short, 3);
assert!(fast.iter().zip(&reference).all(|(a, b)| (a - b).abs() < 1e-6), "{fast:?} {reference:?}");
// Rates with no small common ratio fall back to the per-tap kernel.
assert!(Polyphase::new(44_100, 96_001, &table).is_none());
assert_eq!(resample(&[short.to_vec()], 44_100, 96_001)[0].len(), 7);
}
#[test]
fn fade_shapes_endpoints() {
for s in FadeShape::ALL {
@@ -1,5 +1,6 @@
//! The built-in plugins and their registry.
pub mod auto_pan;
pub mod drums;
pub mod dynamics;
pub mod eq;
@@ -26,12 +27,15 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
&reverb::ROOM_INFO,
&reverb::PLATE_INFO,
&modulation::DELAY_INFO,
&auto_pan::AUTOPAN_INFO,
&modulation::CHORUS_INFO,
&modulation::FLANGER_INFO,
&modulation::PHASER_INFO,
&modulation::RING_INFO,
&harmonic::SAT_INFO,
&harmonic::LOFI_INFO,
&harmonic::RECT_INFO,
&harmonic::SUB_INFO,
&pitch::PITCH_INFO,
&utility::SHIFT_INFO,
&utility::GAIN_INFO,
@@ -58,12 +62,15 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
"room_reverb" => Box::new(reverb::Reverb::room()),
"plate_reverb" => Box::new(reverb::Reverb::plate()),
"mod_delay" => Box::new(modulation::ModDelay::new()),
"auto_pan" => Box::new(auto_pan::AutoPan::new()),
"chorus" => Box::new(modulation::ModFx::chorus()),
"flanger" => Box::new(modulation::ModFx::flanger()),
"phaser" => Box::new(modulation::Phaser::new()),
"ring_mod" => Box::new(modulation::RingMod::new()),
"saturator" => Box::new(harmonic::Saturator::new()),
"lofi" => Box::new(harmonic::LoFi::new()),
"rectifier" => Box::new(harmonic::Rectifier::new()),
"subharmonic" => Box::new(harmonic::SubHarmonic::new()),
"pitch_shifter" => Box::new(pitch::PitchShifter::new()),
"time_shift" => Box::new(utility::TimeShift::new()),
"gain" => Box::new(utility::Gain::new()),
+385
View File
@@ -0,0 +1,385 @@
//! Auto Pan: moves a signal between the left and right channels with an LFO or an envelope.
use std::f32::consts::{FRAC_PI_4, PI, SQRT_2, TAU};
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
use crate::{Category, ParamInfo, Plugin, PluginInfo, Transport, Unit, db_to_gain, gain_to_db};
const SOURCE_CHOICES: &[&str] = &["LFO", "Envelope"];
const PATH_CHOICES: &[&str] = &["Left to Right", "Right to Left"];
const WAVEFORM_CHOICES: &[&str] = &["Sine", "Triangle", "Square", "Saw Up", "Saw Down", "Half Sine", "4 Step Triangle"];
const TRIGGER_CHOICES: &[&str] = &["Free", "Single", "Beat Clock", "Envelope", "Manual"];
const DURATION_CHOICES: &[&str] = &["16th Note", "8th Note", "1 Beat", "2 Beats", "1 Bar", "2 Bars", "4 Bars"];
const SOURCE_ENVELOPE: usize = 1;
const PATH_RIGHT_TO_LEFT: usize = 1;
const TRIGGER_FREE: usize = 0;
const TRIGGER_SINGLE: usize = 1;
const TRIGGER_BEAT_CLOCK: usize = 2;
const TRIGGER_ENVELOPE: usize = 3;
const TRIGGER_MANUAL: usize = 4;
static AUTOPAN_PARAMS: [ParamInfo; 14] = [
lin("output", "Output", -24.0, 12.0, 0.0, Unit::Db),
lin("width", "Width", 0.0, 100.0, 100.0, Unit::Percent),
lin("manual", "Manual", -100.0, 100.0, 0.0, Unit::Percent),
choice("source", "Source", SOURCE_CHOICES, 0),
choice("path", "Path", PATH_CHOICES, 0),
log("rate", "Rate (BPM)", 1.0, 960.0, 120.0, Unit::None),
choice("waveform", "Waveform", WAVEFORM_CHOICES, 0),
choice("trigger", "Trigger", TRIGGER_CHOICES, 0),
toggle("retrigger", "Manual Trigger", false),
toggle("link_tempo", "Link to Tempo", false),
choice("duration", "Duration", DURATION_CHOICES, 4),
lin("threshold", "Threshold", -60.0, -1.0, -30.0, Unit::Db),
log("attack", "Attack", 0.1, 100.0, 5.0, Unit::Ms),
log("release", "Release", 5.0, 2000.0, 150.0, Unit::Ms),
];
pub static AUTOPAN_INFO: PluginInfo = PluginInfo {
id: "auto_pan",
name: "Auto Pan",
short_name: "Auto Pan",
category: Category::Modulation,
params: &AUTOPAN_PARAMS,
is_instrument: false,
audiosuite: true,
};
/// The LFO shape at `t` in 0..=1 of a cycle, from -1 (the start of the path) to 1 (its end).
fn wave_at(waveform: usize, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
let tri = if t < 0.5 { 4.0 * t - 1.0 } else { 3.0 - 4.0 * t };
match waveform {
1 => tri,
2 => {
if t < 0.5 {
-1.0
} else {
1.0
}
}
3 => 2.0 * t - 1.0,
4 => 1.0 - 2.0 * t,
5 => 2.0 * (PI * t).sin() - 1.0,
6 => {
let step = ((tri + 1.0) * 2.0).floor().clamp(0.0, 3.0);
step * 2.0 / 3.0 - 1.0
}
_ => -(TAU * t).cos(),
}
}
/// Length of one Duration choice in quarter notes, for a bar of `bar` quarter notes.
fn duration_quarters(duration: usize, bar: f64) -> f64 {
match duration {
0 => 0.25,
1 => 0.5,
2 => 1.0,
3 => 2.0,
5 => 2.0 * bar,
6 => 4.0 * bar,
_ => bar,
}
}
/// Number of bars in a bar-based Duration choice.
fn duration_bars(duration: usize) -> Option<i64> {
match duration {
4 => Some(1),
5 => Some(2),
6 => Some(4),
_ => None,
}
}
/// Writes one frame: a constant-power pan for stereo and wider, plain gain for mono. Channels past
/// the second only get the output gain.
fn write_frame(io: &mut [Vec<f32>], n: usize, ch: usize, left: f32, right: f32, position: f32, out: f32) {
if ch >= 2 {
// Constant power: sin² + cos² = 1 across the field, and both gains are 1 at the center.
let theta = (position.clamp(-1.0, 1.0) + 1.0) * FRAC_PI_4;
let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0));
if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = left * l * SQRT_2 * out;
}
if let Some(x) = io.get_mut(1).and_then(|b| b.get_mut(n)) {
*x = right * r * SQRT_2 * out;
}
for c in 2..ch {
if let Some(x) = io.get_mut(c).and_then(|b| b.get_mut(n)) {
*x = if x.is_finite() { *x * out } else { 0.0 };
}
}
} else if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = left * out;
}
}
/// Moves the balance of the first two channels with a constant-power law: as the pan position
/// moves right the left channel's gain falls and the right's rises, and each channel stays on its
/// own side. The pan position is `manual · (1 - width) + modulator · width`, where the modulator is
/// the LFO or the envelope follower in -1..=1. At the center both gains are 1, so a stereo signal
/// passes unchanged; a signal that is the same on both channels pans across the field at a
/// constant level.
pub struct AutoPan {
p: Params,
sr: f32,
ch: usize,
transport: Transport,
/// Samples processed since the last `set_transport`, for hosts that call it less than once a block.
since_transport: u64,
was_playing: bool,
/// LFO phase in cycles, in `f64`: at the slowest rates an `f32` step is below half an ULP.
phase: f64,
/// A Single, Envelope or Manual trigger has finished its cycle and holds the last position.
holding: bool,
env: f32,
armed: bool,
/// Samples the envelope has spent below the threshold since the last Envelope trigger. The
/// trigger re-arms once this reaches the Release time, so a longer Release spaces triggers out.
below: u64,
last_retrigger: bool,
last_trigger: usize,
output: Smoother,
width: Smoother,
manual: Smoother,
/// Short declicking smoother on the pan position.
position: Smoother,
/// The next sample starts the position at its target instead of gliding to it.
snap_position: bool,
}
impl Default for AutoPan {
fn default() -> Self {
Self::new()
}
}
impl AutoPan {
pub fn new() -> Self {
let mut a = Self {
p: Params::new(&AUTOPAN_PARAMS),
sr: 48_000.0,
ch: 0,
transport: Transport::default(),
since_transport: 0,
was_playing: false,
phase: 0.0,
holding: false,
env: 0.0,
armed: true,
below: 0,
last_retrigger: false,
last_trigger: TRIGGER_FREE,
output: Smoother::new(1.0),
width: Smoother::new(1.0),
manual: Smoother::new(0.0),
position: Smoother::new(0.0),
snap_position: true,
};
a.update();
a.restart();
a
}
fn update(&mut self) {
self.output.set(db_to_gain(self.p.v(0)));
self.width.set(self.p.v(1) / 100.0);
self.manual.set(self.p.v(2) / 100.0);
let trigger = self.p.choice(7);
if trigger != self.last_trigger {
self.last_trigger = trigger;
self.restart();
}
let retrigger = self.p.on(8);
if retrigger != self.last_retrigger {
self.last_retrigger = retrigger;
if trigger == TRIGGER_MANUAL {
self.phase = 0.0;
self.holding = false;
}
}
}
/// Puts the LFO back at the start of the path. Single runs one cycle from there; Envelope and
/// Manual wait at the start for their first trigger.
fn restart(&mut self) {
self.phase = 0.0;
self.holding = matches!(self.p.choice(7), TRIGGER_ENVELOPE | TRIGGER_MANUAL);
self.armed = true;
self.below = 0;
}
/// LFO frequency in cycles per second.
fn lfo_hz(&self) -> f64 {
let tempo = if self.p.on(9) && self.transport.tempo.is_finite() && self.transport.tempo > 0.0 {
self.transport.tempo
} else {
f64::from(self.p.v(5))
};
tempo / 60.0 / duration_quarters(self.p.choice(10), self.bar_quarters())
}
fn bar_quarters(&self) -> f64 {
let (num, den) = (self.transport.numerator, self.transport.denominator);
if num == 0 || den == 0 { 4.0 } else { (f64::from(num) * 4.0 / f64::from(den)).clamp(0.25, 64.0) }
}
/// The LFO phase at `n` samples into the block when Beat Clock follows a playing transport.
/// Beat Clock restarts the LFO at the start of every Duration and runs it at Rate in between,
/// so a Rate slower than the session tempo plays only part of each cycle before the restart.
fn beat_clock_phase(&self, n: usize, hz: f64) -> Option<f64> {
let t = &self.transport;
if !(t.playing && t.tempo.is_finite() && t.tempo > 0.0 && t.quarters.is_finite()) {
return None;
}
let duration = self.p.choice(10);
let bar = self.bar_quarters();
let dur = duration_quarters(duration, bar);
let elapsed = self.since_transport.saturating_add(n as u64) as f64;
let quarters = t.quarters + elapsed * t.tempo / 60.0 / f64::from(self.sr);
// Bar-based cycles start on the bar line, so a change of time signature does not shift them;
// note and beat cycles follow the song position in quarter notes.
let into = match duration_bars(duration) {
Some(bars) => {
let in_bar = if t.bar_start.is_finite() { quarters - t.bar_start } else { quarters };
((t.bar.rem_euclid(bars) as f64 + in_bar / bar) / bars as f64).rem_euclid(1.0)
}
None => (quarters / dur).rem_euclid(1.0),
};
// Cycles of the LFO per Duration: 1 when its rate matches the session tempo.
let ratio = hz * dur * 60.0 / t.tempo;
Some((into * ratio).rem_euclid(1.0))
}
/// Advances the envelope follower by one sample. `level` is the louder channel's magnitude.
fn follow_envelope(&mut self, level: f32, attack: f32, release: f32) {
let k = if level > self.env { attack } else { release };
self.env = level + (self.env - level) * k;
if self.env < 1.0e-12 {
self.env = 0.0;
}
}
/// Arms the Envelope trigger on a rising threshold crossing and re-arms it after a Release of silence.
fn envelope_trigger(&mut self, threshold: f32, release_samples: u64) {
if self.armed && self.env >= threshold {
self.armed = false;
self.phase = 0.0;
self.holding = false;
self.below = 0;
} else if !self.armed {
if self.env < threshold {
self.below = self.below.saturating_add(1);
self.armed = self.below >= release_samples;
} else {
self.below = 0;
}
}
}
/// The LFO phase for sample `n` of the block. Advances the phase unless a held or Beat Clock position sets it.
fn lfo_phase(&mut self, n: usize, trigger: usize, hz: f64, inc: f64) -> f64 {
match (trigger == TRIGGER_BEAT_CLOCK).then(|| self.beat_clock_phase(n, hz)).flatten() {
Some(phase) => phase,
None if self.holding => self.phase,
None => {
let current = self.phase;
self.phase += inc;
if self.phase >= 1.0 {
if matches!(trigger, TRIGGER_FREE | TRIGGER_BEAT_CLOCK) {
self.phase -= 1.0;
} else {
self.phase = 1.0;
self.holding = true;
}
}
current
}
}
}
/// The modulator in -1..=1: the envelope level above the threshold, or the LFO shape.
fn modulator(&self, envelope_source: bool, threshold_db: f32, waveform: usize, lfo_phase: f64) -> f32 {
if envelope_source {
let above = (gain_to_db(self.env) - threshold_db) / -threshold_db;
2.0 * above.clamp(0.0, 1.0) - 1.0
} else {
wave_at(waveform, lfo_phase as f32)
}
}
}
impl Plugin for AutoPan {
param_plumbing!(AUTOPAN_INFO);
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
self.ch = sane_channels(channels);
for s in [&mut self.output, &mut self.width, &mut self.manual] {
s.set_time(20.0, self.sr);
}
self.position.set_time(2.0, self.sr);
self.update();
self.reset();
}
fn reset(&mut self) {
self.restart();
self.env = 0.0;
for s in [&mut self.output, &mut self.width, &mut self.manual] {
s.snap();
}
self.snap_position = true;
}
fn set_transport(&mut self, transport: &Transport) {
self.transport = *transport;
self.since_transport = 0;
if transport.playing && !self.was_playing && self.p.choice(7) == TRIGGER_SINGLE {
self.restart();
}
self.was_playing = transport.playing;
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.ch.min(io.len());
let frames = frames_in(io, frames, ch);
let hz = self.lfo_hz();
let inc = if hz.is_finite() { (hz / f64::from(self.sr)).clamp(0.0, 0.5) } else { 0.0 };
let waveform = self.p.choice(6);
let trigger = self.p.choice(7);
let envelope_source = self.p.choice(3) == SOURCE_ENVELOPE;
let sign = if self.p.choice(4) == PATH_RIGHT_TO_LEFT { -1.0 } else { 1.0 };
let threshold_db = self.p.v(11);
let threshold = db_to_gain(threshold_db);
let attack = time_coef(self.p.v(12), self.sr);
let release = time_coef(self.p.v(13), self.sr);
let release_samples = ms_to_samples(self.p.v(13), self.sr) as u64;
for n in 0..frames {
let sample = |io: &[Vec<f32>], c: usize| io.get(c).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let left = sample(io, 0);
let right = if ch >= 2 { sample(io, 1) } else { left };
// The louder channel drives the follower, so anti-phase material still has a level.
self.follow_envelope(left.abs().max(right.abs()), attack, release);
if trigger == TRIGGER_ENVELOPE {
self.envelope_trigger(threshold, release_samples);
}
let lfo_phase = self.lfo_phase(n, trigger, hz, inc);
let modulator = self.modulator(envelope_source, threshold_db, waveform, lfo_phase);
let width = self.width.next();
let manual = self.manual.next();
self.position.set(manual * (1.0 - width) + sign * modulator * width);
if self.snap_position {
self.snap_position = false;
self.position.snap();
}
let position = self.position.next();
let out = self.output.next();
write_frame(io, n, ch, left, right, position, out);
}
self.since_transport = self.since_transport.saturating_add(frames as u64);
}
}
+60 -55
View File
@@ -135,6 +135,65 @@ impl Default for DrumSynth {
}
}
/// One output sample of a sounding voice of `kind`, advancing its envelopes.
fn voice_sample(v: &mut DrumVoice, kind: Kind, sr: f32, clap_gap: usize) -> f32 {
match kind {
Kind::Kick | Kind::Tom(_) => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp;
let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + click
}
Kind::Snare => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp * 0.6;
let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + n
}
Kind::Clap => {
// Three quick bursts then a diffuse tail.
let burst = if v.t < clap_gap * 3 {
if v.t.is_multiple_of(clap_gap) {
v.amp = 1.0;
}
v.amp
} else {
0.0
};
let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 });
let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0;
v.amp *= v.amp_coef;
if v.t >= clap_gap * 2 {
v.noise_amp *= v.noise_coef;
}
y
}
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 };
let mut metal = 0.0;
for (m, f) in v.metal.iter_mut().zip(METAL) {
*m += f * scale / sr;
*m -= m.floor();
metal += if *m < 0.5 { 1.0 } else { -1.0 };
}
let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp;
let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp;
v.amp *= v.amp_coef;
y
}
}
}
impl DrumSynth {
pub fn new() -> Self {
let mut voices = [DrumVoice::new(1); VOICES];
@@ -246,61 +305,7 @@ impl DrumSynth {
let mut out = 0.0;
for v in &mut self.voices {
let Some(kind) = v.kind else { continue };
let y = match kind {
Kind::Kick | Kind::Tom(_) => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp;
let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + click
}
Kind::Snare => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp * 0.6;
let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + n
}
Kind::Clap => {
// Three quick bursts then a diffuse tail.
let burst = if v.t < clap_gap * 3 {
if v.t % clap_gap == 0 {
v.amp = 1.0;
}
v.amp
} else {
0.0
};
let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 });
let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0;
v.amp *= v.amp_coef;
if v.t >= clap_gap * 2 {
v.noise_amp *= v.noise_coef;
}
y
}
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 };
let mut metal = 0.0;
for (m, f) in v.metal.iter_mut().zip(METAL) {
*m += f * scale / sr;
*m -= m.floor();
metal += if *m < 0.5 { 1.0 } else { -1.0 };
}
let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp;
let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp;
v.amp *= v.amp_coef;
y
}
};
let y = voice_sample(v, kind, sr, clap_gap);
v.t += 1;
let alive = match kind {
Kind::Clap => v.t < clap_gap * 3 || v.noise_amp > OFF,
+48 -9
View File
@@ -502,7 +502,10 @@ pub struct Maximizer {
sr: f32,
la: usize,
delay: Vec<DelayLine>,
req: Vec<f32>,
win: Vec<(u64, f32)>,
win_head: usize,
win_len: usize,
clock: u64,
smooth: Vec<f32>,
idx: usize,
sum: f64,
@@ -526,7 +529,10 @@ impl Maximizer {
sr: 48_000.0,
la: 1,
delay: Vec::new(),
req: vec![1.0],
win: vec![(0, 1.0)],
win_head: 0,
win_len: 0,
clock: 0,
smooth: vec![1.0],
idx: 0,
sum: 1.0,
@@ -547,6 +553,40 @@ impl Maximizer {
self.ceiling.snap();
self.rel = time_coef(self.p.v(2), self.sr);
}
fn window_min(&mut self, req: f32, la: usize) -> f32 {
let cap = self.win.len();
let t = self.clock;
self.clock = t.saturating_add(1);
if cap == 0 {
return req.min(1.0);
}
while self.win_len > 0 && self.win.get(self.win_head).is_some_and(|&(at, _)| at.saturating_add(la as u64) <= t) {
self.win_head = wrap(self.win_head + 1, cap);
self.win_len -= 1;
}
if !req.is_nan() {
while self.win_len > 0 && self.win.get(wrap(self.win_head + self.win_len - 1, cap)).is_some_and(|&(_, v)| v >= req) {
self.win_len -= 1;
}
if self.win_len == cap {
self.win_head = wrap(self.win_head + 1, cap);
self.win_len -= 1;
}
if let Some(slot) = self.win.get_mut(wrap(self.win_head + self.win_len, cap)) {
*slot = (t, req);
self.win_len += 1;
}
}
match self.win.get(self.win_head) {
Some(&(_, v)) if self.win_len > 0 => v.min(1.0),
_ => 1.0,
}
}
}
fn wrap(i: usize, n: usize) -> usize {
if i < n { i } else { i % n }
}
impl Plugin for Maximizer {
@@ -562,7 +602,7 @@ impl Plugin for Maximizer {
d
})
.collect();
self.req = vec![1.0; self.la];
self.win = vec![(0, 1.0); self.la];
self.smooth = vec![1.0; self.la];
self.drive.set_time(20.0, self.sr);
self.ceiling.set_time(20.0, self.sr);
@@ -572,7 +612,9 @@ impl Plugin for Maximizer {
fn reset(&mut self) {
self.delay.iter_mut().for_each(DelayLine::clear);
self.req.iter_mut().for_each(|v| *v = 1.0);
self.win_head = 0;
self.win_len = 0;
self.clock = 0;
self.smooth.iter_mut().for_each(|v| *v = 1.0);
self.sum = self.la as f64;
self.held = 1.0;
@@ -598,16 +640,13 @@ impl Plugin for Maximizer {
peak = peak.max(x.abs());
}
let req = if peak > ceil { ceil / peak } else { 1.0 };
if let Some(r) = self.req.get_mut(self.idx) {
*r = req;
}
let hold = self.req.iter().copied().fold(1.0f32, f32::min);
let hold = self.window_min(req, la);
self.held = if hold < self.held { hold } else { hold + (self.held - hold) * self.rel };
if let Some(s) = self.smooth.get_mut(self.idx) {
self.sum += f64::from(self.held) - f64::from(*s);
*s = self.held;
}
self.idx = (self.idx + 1) % la;
self.idx = wrap(self.idx + 1, la);
if self.idx == 0 {
// Re-sum once per window to cancel floating-point drift.
self.sum = self.smooth.iter().map(|&v| f64::from(v)).sum();
+291 -3
View File
@@ -1,7 +1,9 @@
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction) and Rectifier.
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction), Rectifier and
//! Subharmonic (octave-down bass synthesis).
use crate::params::{Params, choice, lin, log, param_plumbing};
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr};
use crate::biquad::{Biquad, FilterType};
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr, time_coef};
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};
static SAT_PARAMS: [ParamInfo; 5] = [
@@ -307,3 +309,289 @@ impl Plugin for Rectifier {
}
}
}
// ---------------------------------------------------------------------------------------------
const SUB_RANGES: &[&str] = &["120-180 Hz", "80-120 Hz", "60-90 Hz", "40-60 Hz"];
/// Lower and upper band frequency of each range.
const SUB_RANGE_HZ: [(f32, f32); 4] = [(120.0, 180.0), (80.0, 120.0), (60.0, 90.0), (40.0, 60.0)];
const SUB_CHARACTERS: &[&str] = &["Clean I", "Clean II", "Resonant I", "Resonant II", "Distort I", "Distort II"];
/// Band gains at or below this are off.
const SUB_OFF_DB: f32 = -120.0;
/// Q of the band-pass that picks each band out of the input.
const SUB_BAND_Q: f32 = 2.0;
/// Cutoff of the low-pass after each divider, as a fraction of the band frequency.
const SUB_SMOOTH: f32 = 0.75;
/// Zero-crossing hysteresis, as a fraction of the band's envelope.
const SUB_HYSTERESIS: f32 = 0.1;
/// Release of the band envelope that sets the hysteresis.
const SUB_ENV_RELEASE_MS: f32 = 50.0;
/// Bias of the asymmetric Distort I curve.
const SUB_ASYM: f32 = 0.25;
static SUB_PARAMS: [ParamInfo; 17] = [
lin("input", "Input", -18.0, 18.0, 0.0, Unit::Db),
choice("range", "Range", SUB_RANGES, 2),
lin("lower_gain", "Lower Gain", SUB_OFF_DB, 24.0, 0.0, Unit::Db),
lin("upper_gain", "Upper Gain", SUB_OFF_DB, 24.0, -6.0, Unit::Db),
lin("direct_gain", "Direct Gain", SUB_OFF_DB, 24.0, SUB_OFF_DB, Unit::Db),
toggle("lower_solo", "Lower Solo", false),
toggle("upper_solo", "Upper Solo", false),
toggle("direct_solo", "Direct Solo", false),
lin("drive", "Drive", -18.0, 18.0, 0.0, Unit::Db),
choice("character", "Character", SUB_CHARACTERS, 0),
log("hp_freq", "HP Freq", 5.0, 1000.0, 20.0, Unit::Hz),
log("hp_q", "HP Q", 0.1, 5.0, 0.707, Unit::None),
log("lp_freq", "LP Freq", 5.0, 1000.0, 200.0, Unit::Hz),
log("lp_q", "LP Q", 0.1, 5.0, 0.707, Unit::None),
lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent),
toggle("wet_solo", "Wet Solo", false),
lin("output", "Output", -18.0, 18.0, 0.0, Unit::Db),
];
pub static SUB_INFO: PluginInfo = PluginInfo {
id: "subharmonic",
name: "Subharmonic",
short_name: "SubHarm",
category: Category::Harmonic,
params: &SUB_PARAMS,
is_instrument: false,
audiosuite: true,
};
/// One synthesized band: a band-pass, a divide-by-two flip-flop and a smoothing low-pass.
#[derive(Debug, Clone, Copy)]
struct SubBand {
band: Biquad,
smooth: Biquad,
/// Divider output: ±1, flipped on every rising zero crossing of the band.
flip: f32,
/// The band has gone below the negative threshold since the last flip.
armed: bool,
env: f32,
}
impl SubBand {
fn new() -> Self {
Self { band: Biquad::default(), smooth: Biquad::default(), flip: 1.0, armed: false, env: 0.0 }
}
fn reset(&mut self) {
self.band.reset();
self.smooth.reset();
self.flip = 1.0;
self.armed = false;
self.env = 0.0;
}
/// The octave below the band's part of `x`; `rel` is the envelope's release coefficient.
#[inline]
fn process(&mut self, x: f32, rel: f32) -> f32 {
let b = self.band.process_sample(x);
let a = b.abs();
self.env = flush(if a > self.env { a } else { self.env * rel });
let th = self.env * SUB_HYSTERESIS;
if b < -th {
self.armed = true;
} else if self.armed && b > th {
self.armed = false;
self.flip = -self.flip;
}
self.smooth.process_sample(b * self.flip)
}
}
/// Filter and divider state of one channel.
#[derive(Debug, Clone, Copy)]
struct SubChannel {
/// The lower and the upper band.
bands: [SubBand; 2],
/// Low-pass that takes the direct bass out of the input.
direct: Biquad,
hp: Biquad,
lp: Biquad,
dc: DcBlocker,
}
impl SubChannel {
fn new(sr: f32) -> Self {
Self { bands: [SubBand::new(); 2], direct: Biquad::default(), hp: Biquad::default(), lp: Biquad::default(), dc: DcBlocker::new(5.0, sr) }
}
fn reset(&mut self) {
self.bands.iter_mut().for_each(SubBand::reset);
self.direct.reset();
self.hp.reset();
self.lp.reset();
self.dc.reset();
}
}
/// Subharmonic synthesizer. The Range sets a lower and an upper band frequency. Each band
/// band-passes the input at its frequency, and a divide-by-two flip-flop on the band's rising zero
/// crossings multiplies the band, which moves it an octave down at a level that follows the band.
/// The direct band is the input low-passed at the upper band frequency.
///
/// - The three bands are summed with their gains, raised by Drive and saturated, then
/// shaped by the HP and LP filters. A soloed band plays alone and mutes the dry signal.
/// - Clean I saturates with tanh and Clean II with a softer algebraic curve. Resonant I and II use
/// tanh and raise the Q of the low-pass after each divider to 2 and 4. Distort I bends the curve
/// asymmetrically (even harmonics, DC removed) and Distort II clips hard at full scale.
/// - Below 50 % Mix the dry signal stays at full level and the wet level falls; above 50 % the wet
/// signal stays at full level and the dry level falls. Wet Solo mutes the dry signal.
pub struct SubHarmonic {
p: Params,
sr: f32,
ch: Vec<SubChannel>,
input: Smoother,
bands: [Smoother; 3],
drive: Smoother,
dry: Smoother,
wet: Smoother,
out: Smoother,
env_rel: f32,
}
impl Default for SubHarmonic {
fn default() -> Self {
Self::new()
}
}
impl SubHarmonic {
pub fn new() -> Self {
let mut s = Self {
p: Params::new(&SUB_PARAMS),
sr: 48_000.0,
ch: Vec::new(),
input: Smoother::new(1.0),
bands: [Smoother::new(0.0); 3],
drive: Smoother::new(1.0),
dry: Smoother::new(1.0),
wet: Smoother::new(1.0),
out: Smoother::new(1.0),
env_rel: 0.0,
};
s.update();
s
}
/// The lower and upper band frequencies of the current range.
fn range_hz(&self) -> (f32, f32) {
SUB_RANGE_HZ.get(self.p.choice(1)).copied().unwrap_or((60.0, 90.0))
}
/// Q of the low-pass after each divider for the current Drive Character.
fn smooth_q(&self) -> f32 {
match self.p.choice(9) {
2 => 2.0,
3 => 4.0,
_ => std::f32::consts::FRAC_1_SQRT_2,
}
}
fn update(&mut self) {
let gain = |db: f32| if db <= SUB_OFF_DB { 0.0 } else { db_to_gain(db) };
let solo = [self.p.on(5), self.p.on(6), self.p.on(7)];
let any_solo = solo.contains(&true);
for (k, s) in self.bands.iter_mut().enumerate() {
let heard = !any_solo || solo.get(k).copied().unwrap_or(false);
s.set(if heard { gain(self.p.v(2 + k)) } else { 0.0 });
}
self.input.set(db_to_gain(self.p.v(0)));
self.drive.set(db_to_gain(self.p.v(8)));
self.out.set(db_to_gain(self.p.v(16)));
let m = self.p.v(14) / 100.0;
self.wet.set((2.0 * m).min(1.0));
self.dry.set(if any_solo || self.p.on(15) { 0.0 } else { (2.0 * (1.0 - m)).min(1.0) });
self.env_rel = time_coef(SUB_ENV_RELEASE_MS, self.sr);
let (lo, hi) = self.range_hz();
let sq = self.smooth_q();
let sr = self.sr;
let (hp_f, hp_q, lp_f, lp_q) = (self.p.v(10), self.p.v(11), self.p.v(12), self.p.v(13));
for c in &mut self.ch {
for (b, f) in c.bands.iter_mut().zip([lo, hi]) {
b.band.set(FilterType::BandPass, f, SUB_BAND_Q, 0.0, sr);
b.smooth.set(FilterType::LowPass, f * SUB_SMOOTH, sq, 0.0, sr);
}
c.direct.set(FilterType::LowPass, hi, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr);
c.hp.set(FilterType::HighPass, hp_f, hp_q, 0.0, sr);
c.lp.set(FilterType::LowPass, lp_f, lp_q, 0.0, sr);
}
}
fn smoothers(&mut self) -> impl Iterator<Item = &mut Smoother> {
[&mut self.input, &mut self.drive, &mut self.dry, &mut self.wet, &mut self.out].into_iter().chain(self.bands.iter_mut())
}
}
/// Saturation curve of each Drive Character (index into `SUB_CHARACTERS`).
fn sub_saturate(character: usize, x: f32) -> f32 {
match character {
1 => x / (1.0 + x * x).sqrt(),
4 => ((x + SUB_ASYM).tanh() - SUB_ASYM.tanh()) / (1.0 + SUB_ASYM.tanh()),
5 => x.clamp(-1.0, 1.0),
_ => x.tanh(),
}
}
impl Plugin for SubHarmonic {
param_plumbing!(SUB_INFO);
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
self.ch = vec![SubChannel::new(self.sr); sane_channels(channels)];
let sr = self.sr;
for s in self.smoothers() {
s.set_time(20.0, sr);
}
self.update();
self.reset();
}
fn reset(&mut self) {
self.ch.iter_mut().for_each(SubChannel::reset);
for s in self.smoothers() {
s.snap();
}
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.ch.len().min(io.len());
let frames = frames_in(io, frames, ch);
let character = self.p.choice(9);
let rel = self.env_rel;
for n in 0..frames {
let gin = self.input.next();
let g = [self.bands[0].next(), self.bands[1].next(), self.bands[2].next()];
let drive = self.drive.next();
let dg = self.dry.next();
let wg = self.wet.next();
let o = self.out.next();
for c in 0..ch {
let (Some(buf), Some(st)) = (io.get_mut(c), self.ch.get_mut(c)) else { continue };
let Some(x) = buf.get_mut(n) else { continue };
let dry = if x.is_finite() { *x * gin } else { 0.0 };
let mut sum = st.direct.process_sample(dry) * g[2];
for (band, gain) in st.bands.iter_mut().zip(g) {
sum += band.process(dry, rel) * gain;
}
let mut y = sub_saturate(character, sum * drive);
if character == 4 {
y = st.dc.process(y);
}
let wet = st.lp.process_sample(st.hp.process_sample(y));
*x = (dry * dg + wet * wg) * o;
}
}
}
fn tail_samples(&self) -> usize {
// Time for the slowest, most resonant filter to ring down by 60 dB (6.9 time constants).
let (lo, _) = self.range_hz();
let f = self.p.v(10).min(self.p.v(12)).min(lo * SUB_SMOOTH).max(1.0);
let q = self.p.v(11).max(self.p.v(13)).max(self.smooth_q()).max(SUB_BAND_Q);
(6.9 * q / (std::f32::consts::PI * f) * self.sr) as usize
}
}
+220 -2
View File
@@ -1,7 +1,7 @@
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser.
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser, Ring Modulator.
use crate::params::{Params, choice, lin, log, param_plumbing};
use crate::util::{DelayLine, Lfo, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr};
use crate::util::{DelayLine, Lfo, Rng, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit};
/// Per-channel LFO phase offset for stereo movement (channel 1 is offset by `spread` cycles).
@@ -459,3 +459,221 @@ impl Plugin for Phaser {
ms_to_samples(100.0, self.sr) as usize
}
}
// ---------------------------------------------------------------------------------------------
const RING_EFFECTS: &[&str] = &["Ring", "Frequency Mod", "Resonator +", "Resonator -"];
const RING_SOURCES: &[&str] = &["LFO", "Envelope", "Sample & Hold"];
const RING_MIN_HZ: f32 = 20.0;
/// Octaves the frequency moves at full modulation depth.
const RING_MOD_OCTAVES: f32 = 2.0;
/// Peak delay swing of Frequency Mod.
const RING_FM_SWING_MS: f32 = 1.0;
/// Sample-and-hold noise seed (the same sequence after every reset).
const RING_SEED: u32 = 0x5EED_1234;
static RING_PARAMS: [ParamInfo; 7] = [
choice("effect", "Effect", RING_EFFECTS, 0),
log("frequency", "Frequency", RING_MIN_HZ, 5000.0, 440.0, Unit::Hz),
lin("resonance", "Resonance", 0.0, 95.0, 50.0, Unit::Percent),
choice("mod_source", "Mod Source", RING_SOURCES, 0),
log("mod_rate", "Mod Rate", 0.05, 20.0, 1.0, Unit::Hz),
lin("mod_depth", "Mod Depth", 0.0, 100.0, 0.0, Unit::Percent),
lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent),
];
pub static RING_INFO: PluginInfo = PluginInfo {
id: "ring_mod",
name: "Ring Modulator",
short_name: "RingMod",
category: Category::Modulation,
params: &RING_PARAMS,
is_instrument: false,
audiosuite: true,
};
/// Ring modulation, frequency modulation and positive or negative comb resonators, with the
/// frequency moved by an LFO, an envelope follower or a sample-and-hold source.
///
/// - Ring multiplies the input by a sine carrier at the frequency.
/// - Frequency Mod reads the input through a delay that the carrier swings by ±1 ms.
/// - Resonator + and Resonator - feed the output back after one period of the frequency, with
/// a positive or a negative sign. The output is scaled by `1 - resonance`, so the peak gain at
/// resonance is unity.
///
/// At 100 % depth the modulation moves the frequency by up to two octaves either way. The
/// envelope follower moves it up only.
pub struct RingMod {
p: Params,
sr: f32,
lines: Vec<DelayLine>,
lfo: Lfo,
rng: Rng,
carrier: f32,
env: f32,
env_attack: f32,
env_release: f32,
held: f32,
hold_left: usize,
last_effect: usize,
freq: Smoother,
res: Smoother,
depth: Smoother,
mix: Smoother,
}
impl Default for RingMod {
fn default() -> Self {
Self::new()
}
}
impl RingMod {
pub fn new() -> Self {
let mut r = Self {
p: Params::new(&RING_PARAMS),
sr: 48_000.0,
lines: Vec::new(),
lfo: Lfo::new(),
rng: Rng::new(RING_SEED),
carrier: 0.0,
env: 0.0,
env_attack: 0.0,
env_release: 0.0,
held: 0.0,
hold_left: 0,
last_effect: 0,
freq: Smoother::new(440.0),
res: Smoother::new(0.0),
depth: Smoother::new(0.0),
mix: Smoother::new(1.0),
};
r.update();
r
}
fn update(&mut self) {
self.freq.set(self.p.v(1));
self.res.set(self.p.v(2) / 100.0);
self.depth.set(self.p.v(5) / 100.0);
self.mix.set(self.p.v(6) / 100.0);
}
/// Samples in one period of the lowest frequency the modulation can reach.
fn longest_period(&self) -> f32 {
self.sr / (self.p.v(1) * 2f32.powf(-RING_MOD_OCTAVES * self.p.v(5) / 100.0)).max(1.0)
}
}
impl Plugin for RingMod {
param_plumbing!(RING_INFO);
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
let comb = self.sr / (RING_MIN_HZ * 2f32.powf(-RING_MOD_OCTAVES));
let max = comb.max(ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr)) as usize + 8;
self.lines = (0..sane_channels(channels))
.map(|_| {
let mut d = DelayLine::default();
d.allocate(max);
d
})
.collect();
self.env_attack = time_coef(5.0, self.sr);
self.env_release = time_coef(100.0, self.sr);
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
s.set_time(20.0, self.sr);
}
self.update();
self.reset();
}
fn reset(&mut self) {
self.lines.iter_mut().for_each(DelayLine::clear);
self.lfo.reset();
self.rng = Rng::new(RING_SEED);
self.carrier = 0.0;
self.env = 0.0;
self.held = 0.0;
self.hold_left = 0;
self.last_effect = self.p.choice(0);
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
s.snap();
}
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.lines.len().min(io.len());
let frames = frames_in(io, frames, ch);
let effect = self.p.choice(0);
if effect != self.last_effect {
// The lines hold the previous effect's state.
self.lines.iter_mut().for_each(DelayLine::clear);
self.last_effect = effect;
}
let source = self.p.choice(3);
let rate = self.p.v(4);
let inc = rate / self.sr;
let hold = ((self.sr / rate.max(0.05)) as usize).max(1);
let nyq = self.sr * 0.45;
let swing = ms_to_samples(RING_FM_SWING_MS, self.sr);
for n in 0..frames {
let level = io.iter().take(ch).filter_map(|b| b.get(n)).filter(|x| x.is_finite()).fold(0.0f32, |m, x| m.max(x.abs()));
let k = if level > self.env { self.env_attack } else { self.env_release };
self.env = flush(level + (self.env - level) * k);
let lfo = Lfo::sin_at(self.lfo.tick(inc));
let m = match source {
1 => self.env.min(1.0),
2 => {
if self.hold_left == 0 {
self.held = self.rng.bipolar();
self.hold_left = hold;
}
self.hold_left -= 1;
self.held
}
_ => lfo,
};
let f = (self.freq.next() * 2f32.powf(RING_MOD_OCTAVES * self.depth.next() * m)).clamp(1.0, nyq);
let fb = self.res.next();
let mix = self.mix.next();
self.carrier += f / self.sr;
if self.carrier >= 1.0 {
self.carrier -= 1.0;
}
let carrier = Lfo::sin_at(self.carrier);
for c in 0..ch {
let (Some(buf), Some(line)) = (io.get_mut(c), self.lines.get_mut(c)) else { continue };
let Some(x) = buf.get_mut(n) else { continue };
let dry = if x.is_finite() { *x } else { 0.0 };
let wet = match effect {
1 => {
line.push(dry);
line.read_cubic(2.0 + swing * (1.0 + carrier))
}
2 | 3 => {
let sign = if effect == 2 { 1.0 } else { -1.0 };
let y = flush(dry + sign * fb * line.read_cubic(self.sr / f));
line.push(y);
y * (1.0 - fb)
}
_ => dry * carrier,
};
*x = dry * (1.0 - mix) + wet * mix;
}
}
}
fn tail_samples(&self) -> usize {
match self.p.choice(0) {
1 => ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr) as usize + 4,
2 | 3 => {
// Time for the feedback to fall by 60 dB at the longest period the modulation reaches.
let fb = (self.p.v(2) / 100.0).clamp(0.0, 0.99);
let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 };
(self.longest_period() * repeats) as usize
}
_ => 0,
}
}
}
+27 -15
View File
@@ -201,6 +201,31 @@ impl Reverb {
}
}
/// Input sample `n` of the left and right channels (a mono input feeds both); non-finite reads as 0.
fn input_pair(io: &[Vec<f32>], ch: usize, n: usize) -> (f32, f32) {
let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl };
(xl, xr)
}
/// Blend the wet signal `[mid, left, right]` into sample `n` of the first `ch` channels at mix `m`.
fn mix_out(io: &mut [Vec<f32>], ch: usize, n: usize, wet: [f32; 3], m: f32) {
let [mid, wl, wr] = wet;
for (c, b) in io.iter_mut().take(ch).enumerate() {
let wet = if ch == 1 {
mid
} else if c % 2 == 0 {
wl
} else {
wr
};
if let Some(x) = b.get_mut(n) {
let dry = if x.is_finite() { *x } else { 0.0 };
*x = dry * (1.0 - m) + wet * m;
}
}
}
impl Plugin for Reverb {
fn info(&self) -> &'static PluginInfo {
self.info
@@ -279,8 +304,7 @@ impl Plugin for Reverb {
let size = self.size.value();
let base: [f32; LINES] = self.cfg.delays_ms.map(|ms| ms_to_samples(ms * size, sr));
for n in 0..frames {
let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl };
let (xl, xr) = input_pair(io, ch, n);
let pre = ms_to_samples(self.pre_ms.next(), sr);
let mut ins = [xl, xr];
for (side, x) in ins.iter_mut().enumerate() {
@@ -319,19 +343,7 @@ impl Plugin for Reverb {
let side = 0.5 * (wl - wr) * w;
let (wl, wr) = (mid + side, mid - side);
let m = self.mix.next();
for (c, b) in io.iter_mut().take(ch).enumerate() {
let wet = if ch == 1 {
mid
} else if c % 2 == 0 {
wl
} else {
wr
};
if let Some(x) = b.get_mut(n) {
let dry = if x.is_finite() { *x } else { 0.0 };
*x = dry * (1.0 - m) + wet * m;
}
}
mix_out(io, ch, n, [mid, wl, wr], m);
}
}
+1 -417
View File
@@ -456,420 +456,4 @@ impl Plugin for Sampler {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SampleBuffer;
use std::f32::consts::TAU;
const SR: f32 = 48_000.0;
fn sampler(sample: SampleBuffer) -> Sampler {
let mut s = Sampler::new();
s.prepare(SR, 512, 2);
assert!(s.set_sample(Some(Arc::new(sample))));
s
}
fn mono(sample_rate: f32, data: Vec<f32>) -> SampleBuffer {
SampleBuffer { sample_rate, channels: vec![data] }
}
fn sine(freq: f32, sr: f32, len: usize) -> Vec<f32> {
(0..len).map(|i| 0.5 * (TAU * freq * i as f32 / sr).sin()).collect()
}
/// Renders `len` frames in 512-frame blocks; returns both channels.
fn run(s: &mut Sampler, len: usize) -> [Vec<f32>; 2] {
let mut out = [Vec::new(), Vec::new()];
let mut buf = vec![vec![1.0f32; 512]; 2];
let mut done = 0;
while done < len {
let n = 512.min(len - done);
s.process(&mut buf, n);
out[0].extend_from_slice(&buf[0][..n]);
out[1].extend_from_slice(&buf[1][..n]);
done += n;
}
out
}
fn peak(x: &[f32]) -> f32 {
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
}
/// Frequency from upward zero crossings.
fn freq(x: &[f32], sr: f32) -> f32 {
let ups: Vec<usize> = x.windows(2).enumerate().filter(|(_, w)| w[0] < 0.0 && w[1] >= 0.0).map(|(i, _)| i).collect();
let (Some(a), Some(b)) = (ups.first(), ups.last()) else { return 0.0 };
(ups.len() - 1) as f32 * sr / (b - a) as f32
}
#[test]
fn silent_without_a_sample_and_replaces_the_buffer() {
let mut s = Sampler::new();
s.prepare(SR, 512, 2);
s.note_on(0, 60, 127);
let out = run(&mut s, 4800);
assert_eq!(peak(&out[0]), 0.0);
assert_eq!(peak(&out[1]), 0.0);
}
#[test]
fn root_note_plays_the_sample_verbatim() {
let data = sine(440.0, SR, 9600);
let mut s = sampler(mono(SR, data.clone()));
s.set_param("velocity", 0.0);
s.note_on(0, 60, 100);
let out = run(&mut s, 9600);
// Past the 0.1-ms-ish attack the output is the sample itself, on both sides.
for i in 100..9600 {
assert!((out[0][i] - data[i]).abs() < 1e-6, "frame {i}: {} vs {}", out[0][i], data[i]);
}
assert_eq!(out[0], out[1], "a mono sample feeds both channels");
}
#[test]
fn keys_transpose_from_the_root_note() {
let mut s = sampler(mono(SR, sine(400.0, SR, 96_000)));
s.note_on(0, 72, 127);
let up = run(&mut s, 24_000);
assert!((freq(&up[0][480..], SR) - 800.0).abs() < 2.0, "an octave up: {}", freq(&up[0][480..], SR));
s.reset();
s.note_on(0, 53, 127);
let down = run(&mut s, 24_000);
let want = 400.0 * 2f32.powf(-7.0 / 12.0);
assert!((freq(&down[0][480..], SR) - want).abs() < 2.0);
// Root note 48 makes key 60 play an octave up; transpose and fine tune add on top.
s.reset();
s.set_param("root_note", 48.0);
s.set_param("transpose", -12.0);
s.set_param("fine_tune", 100.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 24_000);
let want = 400.0 * 2f32.powf(1.0 / 12.0);
assert!((freq(&out[0][480..], SR) - want).abs() < 2.0, "{}", freq(&out[0][480..], SR));
}
#[test]
fn key_tracking_off_plays_every_key_at_the_sample_pitch() {
let mut s = sampler(mono(SR, sine(400.0, SR, 48_000)));
s.set_param("key_track", 0.0);
s.note_on(0, 90, 127);
let out = run(&mut s, 24_000);
assert!((freq(&out[0][480..], SR) - 400.0).abs() < 2.0);
}
#[test]
fn converts_the_sample_rate() {
// A 300 Hz tone recorded at 22.05 kHz still plays at 300 Hz on a 48 kHz host.
let mut s = sampler(mono(22_050.0, sine(300.0, 22_050.0, 22_050)));
s.note_on(0, 60, 127);
let out = run(&mut s, 60_000);
assert!((freq(&out[0][480..], SR) - 300.0).abs() < 1.0, "{}", freq(&out[0][480..], SR));
// And it lasts as long as the recording: one second.
assert!(peak(&out[0][46_000..47_500]) > 0.1);
assert_eq!(peak(&out[0][48_200..]), 0.0, "the sample ended");
// A 96 kHz recording is read faster, not cut short.
let mut s = sampler(mono(96_000.0, sine(1000.0, 96_000.0, 96_000)));
s.note_on(0, 60, 127);
let out = run(&mut s, 48_000);
assert!((freq(&out[0][480..], SR) - 1000.0).abs() < 2.0);
}
#[test]
fn key_and_velocity_ranges_gate_notes() {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.set_param("key_low", 48.0);
s.set_param("key_high", 60.0);
s.set_param("vel_low", 40.0);
s.set_param("vel_high", 100.0);
for (note, vel, sounds) in
[(47, 80, false), (61, 80, false), (48, 80, true), (60, 80, true), (55, 39, false), (55, 101, false), (55, 40, true)]
{
s.reset();
s.note_on(0, note, vel);
let out = run(&mut s, 960);
assert_eq!(peak(&out[0]) > 0.0, sounds, "note {note} velocity {vel}");
}
// Reversed ranges are read in order rather than muting everything.
s.set_param("key_low", 60.0);
s.set_param("key_high", 48.0);
s.reset();
s.note_on(0, 50, 80);
assert!(peak(&run(&mut s, 960)[0]) > 0.0);
}
#[test]
fn velocity_sensitivity_scales_level() {
let level = |sens: f32, vel: u8| {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.set_param("velocity", sens);
s.note_on(0, 60, vel);
peak(&run(&mut s, 2400)[0][1000..])
};
assert!((level(100.0, 127) - 0.5).abs() < 1e-4);
assert!((level(100.0, 64) - 0.5 * 64.0 / 127.0).abs() < 1e-4);
assert!((level(0.0, 1) - 0.5).abs() < 1e-4, "no sensitivity: every velocity is full level");
assert!(level(70.0, 30) < level(70.0, 127));
}
#[test]
fn adsr_shapes_the_note() {
let mut s = sampler(mono(SR, vec![0.5; 96_000]));
s.set_param("velocity", 0.0);
s.set_param("attack", 100.0);
s.set_param("decay", 100.0);
s.set_param("sustain", 50.0);
s.set_param("release", 100.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 24_000);
// Linear attack: halfway up at 50 ms, full level at 100 ms, then decay to the sustain level.
assert!((out[0][2400] - 0.25).abs() < 0.01, "attack midpoint {}", out[0][2400]);
assert!(peak(&out[0][4700..4900]) > 0.49);
assert!((out[0][20_000] - 0.25).abs() < 0.005, "sustain {}", out[0][20_000]);
s.note_off(0, 60);
let out = run(&mut s, 24_000);
assert!(out[0][100] > 0.2, "release starts from the sustain level");
assert!(peak(&out[0][5000..]) < 1e-3, "released after 100 ms");
assert_eq!(peak(&out[0][12_000..]), 0.0, "the voice ended");
}
#[test]
fn gate_mode_stops_on_note_off_and_one_shot_plays_through() {
let mut s = sampler(mono(SR, vec![0.5; 24_000]));
s.set_param("release", 1.0);
s.note_on(0, 60, 127);
s.note_off(1000, 60);
let out = run(&mut s, 24_000);
assert!(peak(&out[0][..900]) > 0.1);
assert_eq!(peak(&out[0][2000..]), 0.0, "gate: released at the note-off");
s.set_param("play_mode", 1.0);
s.reset();
s.note_on(0, 60, 127);
s.note_off(1000, 60);
let out = run(&mut s, 30_000);
assert!(peak(&out[0][20_000..23_900]) > 0.1, "one shot: ignores the note-off");
assert_eq!(peak(&out[0][24_100..]), 0.0, "and stops at the end of the sample");
assert!(s.tail_samples() >= 24_000, "a one-shot's tail covers the sample");
// Stopping the transport still silences one-shots.
s.reset();
s.note_on(0, 60, 127);
run(&mut s, 512);
s.all_notes_off();
assert_eq!(peak(&run(&mut s, 4800)[0][2400..]), 0.0);
}
#[test]
fn retriggering_a_held_key_releases_the_old_voice() {
let mut s = sampler(mono(SR, vec![0.25; 48_000]));
s.set_param("velocity", 0.0);
s.set_param("release", 1.0);
s.note_on(0, 60, 127);
s.note_on(256, 60, 127);
let out = run(&mut s, 4800);
assert!(peak(&out[0][2400..]) < 0.26, "voices did not stack: {}", peak(&out[0][2400..]));
// One-shots overlap.
s.set_param("play_mode", 1.0);
s.reset();
s.note_on(0, 60, 127);
s.note_on(256, 60, 127);
let out = run(&mut s, 4800);
assert!((peak(&out[0][2400..]) - 0.5).abs() < 1e-3);
}
#[test]
fn forward_loop_sustains_past_the_end() {
let data = sine(480.0, SR, 4800);
let mut s = sampler(mono(SR, data.clone()));
s.set_param("velocity", 0.0);
s.set_param("loop_mode", 1.0);
s.set_param("loop_start", 50.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 48_000);
assert!(peak(&out[0][40_000..]) > 0.4, "still sounding long after the 0.1 s sample");
// After the first pass it repeats the loop region (2400 frames, whole cycles of 480 Hz).
for i in 0..2400 {
assert!((out[0][4800 + i] - data[2400 + i]).abs() < 1e-4, "frame {i}");
}
assert!((freq(&out[0][4800..], SR) - 480.0).abs() < 1.0);
// Without the loop the note ends with the sample.
s.set_param("loop_mode", 0.0);
s.reset();
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 9600)[0][4900..]), 0.0);
}
#[test]
fn ping_pong_loop_reverses_direction() {
// A rising ramp, looped ping-pong over the whole sample, rises then falls.
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
let mut s = sampler(mono(SR, data));
s.set_param("velocity", 0.0);
s.set_param("loop_mode", 2.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 3000);
assert!(out[0][900] > out[0][500], "rising");
assert!(out[0][1500] < out[0][1100], "falling after the loop end");
assert!(out[0][2500] > out[0][2100], "rising again after the loop start");
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
}
/// Where a ping-pong voice is after `n` steps, by walking a triangle wave over the loop.
fn triangle(start: f64, forward: bool, step: f64, n: u32, ls: f64, le: f64) -> (f64, bool) {
let width = le - ls;
// Unfold the walk onto one line, then fold it back: phase 0..width rising, width..2 width falling.
let signed = if forward { start - ls } else { (le - start) + width };
let t = (signed + f64::from(n) * step).rem_euclid(2.0 * width);
if t < width { (ls + t, true) } else { (le - (t - width), false) }
}
#[test]
fn a_ping_pong_step_wider_than_the_loop_keeps_the_phase() {
let (ls, le) = (100.0, 110.0);
// Steps below, equal to and above the loop's width, with fractions, both ways round.
for step in [0.37, 3.0, 9.99, 10.0, 10.5, 23.7, 25.0, 31.3, 99.9] {
for forward in [true, false] {
let mut v = Voice::new();
v.pos = if forward { 102.5 } else { 107.25 };
v.forward = forward;
v.step = step;
let start = v.pos;
for n in 1..=40u32 {
advance(&mut v, 1000.0, Some((ls, le)), LoopMode::PingPong);
let (want, want_forward) = triangle(start, forward, step, n, ls, le);
assert!(v.pos >= ls - 1e-9 && v.pos <= le + 1e-9, "step {step} n {n}: {} left the loop", v.pos);
// At an exact end the direction is where the position is heading next.
let at_end = (want - ls).abs() < 1e-6 || (want - le).abs() < 1e-6;
assert!((v.pos - want).abs() < 1e-6, "step {step} {forward} n {n}: at {} want {want}", v.pos);
assert!(at_end || v.forward == want_forward, "step {step} {forward} n {n}: direction");
}
}
}
}
#[test]
fn a_ping_pong_step_below_the_loop_width_moves_as_before() {
let mut v = Voice::new();
v.pos = 108.0;
v.step = 5.0;
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
assert_eq!((v.pos, v.forward), (107.0, false), "one reflection off the end");
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
assert_eq!((v.pos, v.forward), (102.0, false));
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
assert_eq!((v.pos, v.forward), (103.0, true), "and one off the start");
}
#[test]
fn a_loop_too_short_to_loop_plays_once() {
let mut s = sampler(mono(SR, vec![0.5; 1000]));
s.set_param("loop_mode", 1.0);
s.set_param("loop_start", 40.0);
s.set_param("loop_end", 40.0);
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 4800)[0][1100..]), 0.0);
}
#[test]
fn sample_start_skips_into_the_sample() {
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
let mut s = sampler(mono(SR, data));
s.set_param("velocity", 0.0);
s.set_param("attack", 0.1);
s.set_param("start", 50.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 2000);
assert!((out[0][10] - 0.509).abs() < 0.002, "{}", out[0][10]);
assert_eq!(peak(&out[0][520..]), 0.0, "half the sample left");
}
#[test]
fn stereo_samples_keep_their_sides() {
let sample = SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 4800], vec![-0.25; 4800]] };
let mut s = sampler(sample);
s.set_param("velocity", 0.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 2400);
assert!((out[0][1000] - 0.5).abs() < 1e-6);
assert!((out[1][1000] + 0.25).abs() < 1e-6);
}
#[test]
fn note_offsets_are_sample_accurate() {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.note_on(300, 60, 127);
let out = run(&mut s, 512);
assert!(out[0][..300].iter().all(|&v| v == 0.0));
assert!(out[0][301..].iter().any(|&v| v != 0.0));
}
#[test]
fn many_notes_steal_voices_and_stay_bounded() {
let mut s = sampler(mono(SR, sine(220.0, SR, 48_000)));
s.set_param("level", 12.0);
for n in 0..100u8 {
s.note_on(usize::from(n), n, 127);
}
let out = run(&mut s, 9600);
assert!(out[0].iter().all(|v| v.is_finite()));
assert!(s.voices.iter().filter(|v| v.active()).count() <= VOICES);
}
#[test]
fn a_new_sample_stops_the_old_voices() {
let mut s = sampler(mono(SR, vec![0.5; 48_000]));
s.note_on(0, 60, 127);
run(&mut s, 512);
assert!(s.set_sample(Some(Arc::new(mono(SR, vec![0.1; 10])))));
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
assert!(s.set_sample(None));
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
}
#[test]
fn hostile_samples_and_params_never_panic() {
let samples = [
SampleBuffer { sample_rate: f32::NAN, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: 0.0, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: f32::INFINITY, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: 1.0e-30, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: SR, channels: vec![] },
SampleBuffer { sample_rate: SR, channels: vec![vec![]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 100], vec![0.5; 3]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 1]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![f32::NAN; 100]; 9] },
];
let params: Vec<(&str, f32)> = SAMPLER_PARAMS.iter().flat_map(|p| [(p.id, f32::NAN), (p.id, f32::INFINITY), (p.id, -1.0e30)]).collect();
for sample in samples {
let mut s = Sampler::new();
s.prepare(f32::NAN, 0, 99);
s.set_sample(Some(Arc::new(sample)));
for (id, v) in &params {
assert!(s.set_param(id, *v));
for n in [0u8, 60, 127, 255] {
s.note_on(0, n, 255);
s.note_on(7, n, 1);
}
let mut io = vec![vec![0.0f32; 64]; 3];
s.process(&mut io, 1000);
assert!(io.iter().flatten().all(|v| v.is_finite()), "{id} = {v}");
s.process(&mut [], 64);
let _ = s.tail_samples();
}
}
// Loops with every mode at the extremes of pitch.
let mut s = sampler(mono(SR, sine(100.0, SR, 1000)));
for mode in [0.0, 1.0, 2.0] {
s.set_param("loop_mode", mode);
for (t, root) in [(48.0, 0.0), (-48.0, 127.0)] {
s.set_param("transpose", t);
s.set_param("root_note", root);
s.note_on(0, 127, 127);
s.note_on(0, 0, 127);
let out = run(&mut s, 4096);
assert!(out[0].iter().all(|v| v.is_finite() && v.abs() < 4.0), "mode {mode} transpose {t}");
}
}
}
}
mod tests;

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