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
232 changed files with 30238 additions and 24476 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')
+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
+1
View File
@@ -55,3 +55,4 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts
| `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
+529 -227
View File
File diff suppressed because it is too large Load Diff
+14 -11
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.13.1"
version = "0.14.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -29,34 +29,35 @@ 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 = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
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 = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
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
# 650b1c0b058017c4eb3af06d5b39586d6732e07b is the revision produced by the separate craft-libs current-main-0135
# integration: 25da's egui-0.37 port + 6e7f's docking Workspace/DockCommand + final egui/winit
# pins. Raw 6e7f/16fb3d46 contain the older egui 0.36 UI; keep this reviewed
# immutable revision to retain the required docking APIs on egui 0.37.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "650b1c0b058017c4eb3af06d5b39586d6732e07b", features = ["serde"] }
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).
@@ -69,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."*"]
+4 -3
View File
@@ -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
+3 -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-24 · **Last updated:** 2026-10-25 · **Change:** trivial (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
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -122,6 +122,7 @@ 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.
@@ -175,6 +176,7 @@ 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 |
+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 -680
View File
@@ -1,684 +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] [--dither | --no-dither]
//! dithers only when the output loses resolution, unless told
//! 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 [--start T --end T]] (one `ID JSON` per line;
//! without --start/--end, --bounce covers the whole session)
//! 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().enumerate().find_map(|(i, a)| {
if a == flag { args.get(i + 1).cloned() } else { a.strip_prefix(flag).and_then(|rest| rest.strip_prefix('=')).map(str::to_owned) }
})
}
/// Every option a subcommand accepts, with whether it carries a value.
type Options = [(&'static str, bool)];
/// A misspelled option, or one whose value is missing, reads as absent through `arg_value`, so the
/// subcommand would run without what was asked for and still report success. Reject both instead.
fn check_options(args: &[String], allowed: &Options) -> Result<(), String> {
let mut i = 0;
while i < args.len() {
let Some(name) = args[i].strip_prefix("--") else {
i += 1;
continue;
};
let (name, inline) = name.split_once('=').map_or((name, None), |(n, v)| (n, Some(v)));
let flag = format!("--{name}");
let Some(&(_, takes_value)) = allowed.iter().find(|(f, _)| f == &flag) else {
return Err(format!("unknown option {flag}"));
};
match (takes_value, inline) {
(false, Some(_)) => return Err(format!("{flag} takes no value")),
(true, None) => {
// A following option is the next flag, not this one's value.
if args.get(i + 1).is_none_or(|v| v.starts_with("--")) {
return Err(format!("{flag} needs a value"));
}
i += 1;
}
_ => {}
}
i += 1;
}
Ok(())
}
/// `--in`, `--demo`/`--sample`, `--bounce`, `--start`, `--end`, `--save` and `--inspect`, read by
/// `load_engine` and `finish` and so shared by `run` and `script`.
const ENGINE_OPTIONS: &Options = &[
("--in", true),
("--demo", false),
("--sample", false),
("--bounce", true),
("--start", true),
("--end", true),
("--save", true),
("--inspect", false),
];
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") };
const OPTIONS: &Options = &[("--rate", true), ("--bit-depth", true), ("--normalize", false), ("--dither", false), ("--no-dither", false)];
if let Err(e) = check_options(&args[2..], OPTIONS) {
return fail(e);
}
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 dither_flag = match parse_dither(args) {
Ok(d) => d,
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 (info, 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;
}
let normalize = args.iter().any(|a| a == "--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));
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, 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}`")),
}
}
/// `--dither` / `--no-dither`: `Some` when the caller chose, `None` to decide from the depths.
fn parse_dither(args: &[String]) -> Result<Option<bool>, String> {
match (args.iter().any(|a| a == "--dither"), args.iter().any(|a| a == "--no-dither")) {
(true, true) => Err("--dither and --no-dither: choose one".into()),
(true, false) => Ok(Some(true)),
(false, true) => Ok(Some(false)),
(false, false) => Ok(None),
}
}
/// 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,
}
}
/// 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);
}
// 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));
}
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 options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--cmd", true)]).collect();
if let Err(e) = check_options(args, &options) {
return fail(e);
}
let mut e = match load_engine(args) {
Ok(e) => e,
Err(err) => return fail(err),
};
let mut it = args.iter();
while let Some(a) = it.next() {
let spec = match a.strip_prefix("--cmd=") {
Some(inline) => inline,
None if a == "--cmd" => match it.next() {
Some(next) => next,
None => continue,
},
None => continue,
};
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}")),
}
}
finish(&mut e, args)
}
fn script(args: &[String]) -> ExitCode {
let Some(path) = args.first() else { return fail("script FILE") };
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--keep-going", false)]).collect();
if let Err(e) = check_options(&args[1..], &options) {
return fail(e);
}
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 convert_dither_flags() {
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(parse_dither(&args(&["in.wav", "out.wav"])), Ok(None));
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--dither"])), Ok(Some(true)));
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--no-dither"])), Ok(Some(false)));
assert!(parse_dither(&args(&["in.wav", "out.wav", "--dither", "--no-dither"])).is_err());
}
#[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));
}
#[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");
}
}
+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);
}
+7 -7
View File
@@ -209,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");
@@ -224,9 +224,9 @@ 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
+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>'",
);
}
+12 -4
View File
@@ -58,7 +58,11 @@ fn convert_rejects_an_unknown_option_instead_of_writing_the_file() {
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!("unknown option {bad}")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
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");
}
}
@@ -71,7 +75,11 @@ fn convert_rejects_an_option_whose_value_is_missing() {
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!("{bad} needs a value")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
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");
}
}
@@ -104,11 +112,11 @@ fn run_rejects_a_misspelled_or_value_less_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("--bounce needs a value"), "{}", String::from_utf8_lossy(&o.stderr));
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("unknown option --bounces"), "{}", String::from_utf8_lossy(&o.stderr));
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");
}
+1
View File
@@ -15,6 +15,7 @@ path = "src/main.rs"
[target.'cfg(target_arch = "wasm32")'.dependencies]
soundcraft-engine = { workspace = true }
soundcraft-audio-io.workspace = true
soundcraft-ui-egui.workspace = true
soundcraft-playback = { workspace = true }
soundcraft-model = { workspace = true }
+9 -12
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,7 +129,7 @@ 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();
@@ -228,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)?;
+3
View File
@@ -16,8 +16,11 @@ asio = ["soundcraft-playback/asio"]
[dependencies]
soundcraft-engine.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-ui-egui.workspace = true
soundcraft-playback.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
+41 -17
View File
@@ -17,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) {
@@ -39,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()) {
@@ -88,6 +81,7 @@ 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::<docking::Workspace>(value.clone()) {
@@ -117,6 +111,7 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu
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());
@@ -213,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()));
+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);
}
+47 -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,17 +13,22 @@
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) {
@@ -43,20 +48,20 @@ impl eframe::App for App {
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().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);
@@ -72,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.
@@ -98,7 +103,7 @@ impl eframe::App for App {
}
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();
}
@@ -109,42 +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 {
// Decoded in the background so a long file doesn't freeze the window (#597).
let at = app.engine.session().edit.selection.start;
soundcraft_ui_egui::import::start(app, serde_json::json!({"path": p, "at": at}));
Ok(serde_json::Value::Null)
};
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) => {
@@ -154,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}");
@@ -172,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()) {
@@ -191,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())),
}
}
@@ -217,34 +190,22 @@ fn main() -> eframe::Result {
}
// 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}"),
@@ -257,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")
@@ -278,7 +239,7 @@ fn main() -> eframe::Result {
// 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 std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("SOUNDCRAFT_WAYLAND").is_none() {
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();
@@ -292,9 +253,10 @@ fn main() -> eframe::Result {
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.set_startup_update_check(true);
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
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 {
@@ -302,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")]
@@ -319,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
+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"
+102 -27
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 {
@@ -192,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.
@@ -270,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)
+43 -1
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 {
@@ -235,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}");
}
}
}
+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");
}
+2 -1
View File
@@ -1,6 +1,7 @@
//! 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;
+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;
}
}
+10 -2
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;
@@ -231,7 +231,15 @@ impl Plugin for ClapPlugin {
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) {
@@ -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);
+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;
+18 -1
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.
+85 -60
View File
@@ -94,6 +94,29 @@ fn duration_bars(duration: usize) -> Option<i64> {
}
}
/// 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
@@ -230,6 +253,63 @@ impl AutoPan {
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 {
@@ -283,50 +363,12 @@ impl Plugin for AutoPan {
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.
let level = left.abs().max(right.abs());
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;
}
self.follow_envelope(left.abs().max(right.abs()), attack, release);
if trigger == TRIGGER_ENVELOPE {
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;
}
}
self.envelope_trigger(threshold, release_samples);
}
let lfo_phase = 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
}
};
let modulator = 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)
};
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);
@@ -336,24 +378,7 @@ impl Plugin for AutoPan {
}
let position = self.position.next();
let out = self.output.next();
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;
}
write_frame(io, n, ch, left, right, position, out);
}
self.since_transport = self.since_transport.saturating_add(frames as u64);
}
+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();
+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;
+413
View File
@@ -0,0 +1,413 @@
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}");
}
}
}
+2 -1
View File
@@ -34,7 +34,8 @@ pub(crate) fn time_coef(ms: f32, sr: f32) -> f32 {
/// Flushes denormals and non-finite values in feedback paths to zero.
#[inline]
pub(crate) fn flush(x: f32) -> f32 {
if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 }
let a = x.abs();
if (a > 1.0e-25) & (a < f32::INFINITY) { x } else { 0.0 }
}
/// Number of frames a plugin may safely touch.
+91
View File
@@ -298,3 +298,94 @@ fn spectrum_edge_cases() {
let m = magnitude_db(&[f32::NAN; 64], 64);
assert!(m.iter().all(|v| v.is_finite()));
}
const PIN_LUFS: u32 = 3_215_227_935;
const PIN_TRUE_PEAK: u32 = 3_162_870_746;
fn noise(len: usize, seed: u64, amp: f32) -> Vec<f32> {
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
(0..len)
.map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
((x >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0) * amp
})
.collect()
}
fn bits_hash(chans: &[Vec<f32>]) -> u64 {
chans.iter().flatten().fold(0xcbf2_9ce4_8422_2325u64, |h, v| (h ^ u64::from(v.to_bits())).wrapping_mul(0x0100_0000_01b3))
}
fn run_blocks(p: &mut dyn soundcraft_dsp::Plugin, input: &[Vec<f32>], block: usize) -> Vec<Vec<f32>> {
let len = input[0].len();
let mut out = vec![Vec::with_capacity(len); input.len()];
let mut i = 0;
while i < len {
let n = block.min(len - i);
let mut io: Vec<Vec<f32>> = input.iter().map(|c| c[i..i + n].to_vec()).collect();
p.process(&mut io, n);
for (o, c) in out.iter_mut().zip(io) {
o.extend(c);
}
i += n;
}
out
}
#[test]
fn loudness_and_true_peak_do_not_depend_on_block_size() {
let sr = 48_000.0;
let len = 96_000;
let l: Vec<f32> = sine(997.0, 0.5, sr, len).iter().zip(noise(len, 1, 0.3)).map(|(a, b)| a + b).collect();
let r: Vec<f32> = sine(11_025.0, 0.7, sr, len).iter().zip(noise(len, 2, 0.2)).map(|(a, b)| a + b).collect();
let io = [l, r];
let run = |block: usize| {
let mut m = LoudnessMeter::new(sr, 2);
let mut i = 0;
while i < len {
let n = block.min(len - i);
let chunk: Vec<Vec<f32>> = io.iter().map(|c| c[i..i + n].to_vec()).collect();
m.process(&chunk, n);
i += n;
}
(m.integrated_lufs().to_bits(), m.true_peak_db().to_bits(), m.momentary_lufs().to_bits())
};
let whole = run(len);
for block in [1, 7, 1024, 4801] {
assert_eq!(run(block), whole, "block {block}");
}
let near = |got: u32, pin: u32| (f32::from_bits(got) - f32::from_bits(pin)).abs() < 1e-3;
assert!(near(whole.0, PIN_LUFS) && near(whole.1, PIN_TRUE_PEAK), "lufs {} dB, true peak {} dB", f32::from_bits(whole.0), f32::from_bits(whole.1));
}
#[test]
fn peak_skips_non_finite_samples_at_any_length() {
for len in 0..40 {
let mut x = noise(len, len as u64 + 3, 0.9);
for (at, v) in [(1, f32::NAN), (len.saturating_sub(1), f32::INFINITY), (len / 2, f32::NEG_INFINITY)] {
if len > 3 {
x[at] = v;
}
}
let naive = x.iter().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs()));
assert_eq!(offline::channel_peak(&x).to_bits(), naive.to_bits(), "len {len}");
let y = noise(len, 99, 0.5);
let both = y.iter().fold(naive, |m, v| m.max(v.abs()));
assert_eq!(offline::peak(&[x, y]).to_bits(), both.to_bits(), "len {len}");
}
}
#[test]
fn maximizer_and_plate_reverb_output_does_not_depend_on_block_size() {
let len = 48_000;
let loud = [noise(len, 5, 1.6), noise(len, 6, 1.2)];
let mut burst = [noise(len, 7, 0.5), noise(len, 8, 0.5)];
for c in &mut burst {
c[4_800..].iter_mut().for_each(|v| *v = 0.0);
}
for (id, input) in [("maximizer", &loud), ("plate_reverb", &burst)] {
let mut hashes = Vec::new();
for block in [64, 1000, 4096] {
let mut p = create(id).unwrap();
p.prepare(48_000.0, 4096, 2);
hashes.push(bits_hash(&run_blocks(p.as_mut(), input, block)));
}
assert!(hashes.iter().all(|h| *h == hashes[0]), "{id}: {hashes:x?}");
}
}
+33
View File
@@ -0,0 +1,33 @@
use serde_json::{Value, json};
use std::time::Instant;
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(out) = args.first() else {
eprintln!("usage: bounce_bench OUT.wav [reps] [JSON params]");
std::process::exit(2);
};
let reps = args.get(1).and_then(|r| r.parse().ok()).unwrap_or(5usize);
let mut params = args.get(2).and_then(|p| serde_json::from_str::<Value>(p).ok()).unwrap_or_else(|| json!({}));
if let Some(m) = params.as_object_mut() {
m.insert("path".into(), json!(out));
}
let mut e = soundcraft_engine::demo::demo_engine();
let mut times = Vec::new();
let mut last = Value::Null;
for _ in 0..reps {
let t0 = Instant::now();
match e.execute("file.bounce_mix", &params) {
Ok(v) => last = v,
Err(err) => {
eprintln!("bounce failed: {err}");
std::process::exit(1);
}
}
times.push(t0.elapsed().as_secs_f64());
}
let total: f64 = times.iter().sum();
let min = times.iter().copied().fold(f64::INFINITY, f64::min);
println!("{last}");
println!("total {total:.4} s, min {min:.4} s over {reps} bounces");
}
+2 -2
View File
@@ -310,7 +310,7 @@ pub fn recover(e: &mut Engine, left: &Leftover) -> Result<Vec<String>> {
let mut r = Engine::default();
let session = left.dir.join(SESSION);
let links = left.origin.as_deref().map_or(session.as_path(), Path::new);
let mut missing = open_session_linked(&mut r, &session.to_string_lossy(), links)?;
let mut missing = open_session_linked(&mut r, &session, links)?;
let mut s = r.session().clone();
let unfinished = unfinished_takes(left, &s, &mut missing);
if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) {
@@ -535,7 +535,7 @@ mod tests {
w.flush().unwrap();
let file = std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join(SESSION)).collect::<Vec<_>>();
let mut r = Engine::default();
open_session(&mut r, &file[0].to_string_lossy()).unwrap();
open_session(&mut r, &file[0]).unwrap();
assert_eq!((file.len(), r.session().sources.len()), (1, 5));
w.close(false);
w.flush().unwrap();
+13 -9
View File
@@ -20,6 +20,18 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// The most frames a process can take in: these processes cut their output at MAX_OFFLINE_SAMPLES frames.
fn frame_limit(name: &str, ratio: f64, semitones: f32, sr: f64, to: u32, selection: SpeedRampSelection) -> usize {
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
match name {
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr),
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
_ => usize::MAX,
}
}
/// Process each target clip's audio and replace the clip with one backed by a new source.
fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let name = str_param(p, "process").or_else(|| str_param(p, "plugin")).ok_or_else(|| bad("audiosuite.process", "`process` required"))?.to_string();
@@ -63,15 +75,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let change = if get("change", 0.0) >= 0.5 { SpeedRampChange::SpeedUp } else { SpeedRampChange::SlowDown };
let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo };
let fades = get("fades", 1.0) >= 0.5;
// These processes cut their output at MAX_OFFLINE_SAMPLES frames.
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
let most = match name.as_str() {
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr.as_f64()),
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
_ => usize::MAX,
};
let most = frame_limit(&name, ratio, semitones, sr.as_f64(), to, selection);
let mut done = 0;
for id in ids {
let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
+7 -416
View File
@@ -92,11 +92,9 @@ pub fn specs() -> Vec<CommandSpec> {
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
let r = range_param(e, "file.bounce_stems", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let format = match str_param(p, "format") {
Some("aiff") | Some("aif") => soundcraft_audio_io::FileFormat::Aiff,
Some("flac") => soundcraft_audio_io::FileFormat::Flac,
_ => soundcraft_audio_io::FileFormat::Wav,
};
let format = str_param(p, "format")
.and_then(soundcraft_audio_io::FileFormat::from_write_extension)
.unwrap_or(soundcraft_audio_io::FileFormat::Wav);
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
@@ -313,8 +311,9 @@ pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let ext = std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let format_name = str_param(p, "format").unwrap_or(ext.as_str());
let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| {
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS))
let format = soundcraft_audio_io::FileFormat::from_write_extension(format_name).ok_or_else(|| {
let supported = soundcraft_audio_io::FileFormat::writable_extensions().join(", ");
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {supported})"))
})?;
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
@@ -347,412 +346,4 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Engine;
use soundcraft_audio_io::{AudioBuffer, EncodeOptions};
use soundcraft_model::ChannelFormat;
fn tone_wav(dir: &std::path::Path, name: &str) -> String {
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.1).sin() * 0.2).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
/// Issue #597: the app decodes on a worker thread and hands the audio over; the command then
/// only places it. Audio decoded for another rate is ignored and the file is read again.
#[test]
fn import_audio_places_audio_offered_by_a_worker() {
let dir = std::env::temp_dir().join(format!("sc-import-offered-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
let mut e = Engine::default();
let rate = e.session().sample_rate.hz();
let decoded = std::thread::spawn({
let wav = wav.clone();
move || crate::io::decode_import(&wav, rate)
})
.join()
.unwrap()
.unwrap();
assert_eq!(decoded.frames(), 4_800);
e.offer_import(decoded);
// The file changes after decoding: the offered audio is placed, not the new bytes.
std::fs::write(&wav, b"not audio").unwrap();
let r = e.execute("file.import_audio", &json!({"path": wav, "at": 960})).unwrap();
assert_eq!(r["imported"][0]["frames"], 4_800);
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].clips()[0].start, 960);
assert_eq!(e.session().sources[0].path, wav);
// Taken once: the next import reads the (now broken) file.
assert!(e.execute("file.import_audio", &json!({"path": wav})).is_err());
// Decoded for another rate: ignored, so the broken file is read and refused.
let good = tone_wav(&dir, "good.wav");
let other = crate::io::decode_import(&good, rate / 2).unwrap();
std::fs::write(&good, b"not audio").unwrap();
e.offer_import(other);
assert!(e.execute("file.import_audio", &json!({"path": good})).is_err());
e.drop_offered_imports();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn export_session_text_needs_replace_to_overwrite() {
let dir = std::env::temp_dir().join(format!("sc-export-text-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("info.txt");
let path_str = path.to_string_lossy().into_owned();
let mut e = Engine::default();
e.execute("file.export_session_text", &json!({"path": path_str})).unwrap();
std::fs::write(&path, "keep me").unwrap();
let err = e.execute("file.export_session_text", &json!({"path": path_str})).unwrap_err();
assert!(err.to_string().contains("replace: true"), "{err}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "keep me");
e.execute("file.export_session_text", &json!({"path": path_str, "replace": true})).unwrap();
assert_ne!(std::fs::read_to_string(&path).unwrap(), "keep me");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_audio_honours_new_tracks() {
let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
// Default / new_tracks:true: create a new track even when one is selected.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
let target = e.session().tracks[0].id;
e.session_mut().edit.selected_tracks = vec![target];
e.execute("file.import_audio", &json!({"path": wav, "new_tracks": true})).unwrap();
assert_eq!(e.session().tracks.len(), 2);
assert_eq!(e.session().track(target).unwrap().clips().len(), 0);
assert_eq!(e.session().tracks[1].clips().len(), 1);
// new_tracks:false with a selected audio track: import onto it.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
let target = e.session().tracks[0].id;
e.session_mut().edit.selected_tracks = vec![target];
e.execute("file.import_audio", &json!({"path": wav, "new_tracks": false})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().track(target).unwrap().clips().len(), 1);
// new_tracks:false with no selection and no track: clear error.
let mut e = Engine::default();
let err = e.execute("file.import_audio", &json!({"path": wav, "new_tracks": false})).unwrap_err();
assert!(err.to_string().contains("new_tracks: false"), "{err}");
// Explicit track wins over new_tracks:true.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
e.execute("file.import_audio", &json!({"path": wav, "track": "Target", "new_tracks": true})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].clips().len(), 1);
// Omitted new_tracks still creates a track on a blank session.
let mut e = Engine::default();
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].kind, TrackKind::Audio);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Mono);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn bounce_refuses_formats_it_cannot_write() {
let mut e = Engine::default();
let dir = std::env::temp_dir().join(format!("sc-bounce-ext-{}", std::process::id()));
assert!(std::fs::create_dir_all(&dir).is_ok());
for name in ["mix.mp3", "mix.ogg", "mix.xyz"] {
let path = dir.join(name);
let err = e.execute("file.bounce_mix", &json!({"path": path.to_string_lossy(), "start": 0, "end": 480})).map(|_| ()).unwrap_err();
assert!(err.to_string().contains("supported: wav"), "{name}: {err}");
assert!(!path.exists(), "{name} must not be written");
}
let ok = dir.join("mix.flac");
assert!(e.execute("file.bounce_mix", &json!({"path": ok.to_string_lossy(), "start": 0, "end": 480})).is_ok());
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
#[test]
fn bounce_source_renders_the_named_bus() {
let dir = std::env::temp_dir().join(format!("sc-bounce-source-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
let mut e = Engine::default();
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
let stem = e.session_mut().add_bus("Stem", ChannelFormat::Stereo);
e.session_mut().add_bus("Empty", ChannelFormat::Stereo);
let track = e.session().tracks[0].id;
{
let tr = e.session_mut().track_mut(track).unwrap();
let mut send = soundcraft_model::SendSlot::new(soundcraft_model::Route::Bus(stem));
send.level_db = -12.0;
tr.mixer.sends[0] = Some(send);
}
let bounce = |e: &mut Engine, name: &str, source: Option<&str>| {
let path = dir.join(name);
let mut params = json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false});
if let Some(source) = source {
params["source"] = json!(source);
}
let value = e.execute("file.bounce_mix", &params).unwrap();
let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
(std::fs::read(&path).unwrap(), peak)
};
let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None);
let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main"));
let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM"));
let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty"));
assert_eq!(main_bytes, named_main);
assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}");
assert!(main_peak > -20.0, "{main_peak}");
assert_ne!(stem_bytes, main_bytes);
assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}");
assert_ne!(empty_bytes, main_bytes);
assert!(empty_peak < -100.0, "{empty_peak}");
let missing = dir.join("missing.wav");
let err = e
.execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false}))
.unwrap_err();
assert!(err.to_string().contains("unknown bus"), "{err}");
assert!(!missing.exists());
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
fn remove_media(dir: &std::path::Path) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = entry.path();
if p.is_dir() {
remove_media(&p);
} else if p.extension().is_some_and(|x| x != "scraft") {
let _ = std::fs::remove_file(&p);
}
}
}
#[test]
fn session_open_reports_missing_media() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-open-missing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Gone.scraft").to_string_lossy().into_owned();
e.execute("session.save_as", &json!({"path": path})).unwrap();
let mut ok = Engine::default();
assert!(ok.execute("session.open", &json!({"path": path})).unwrap()["missing"].as_array().is_some_and(Vec::is_empty));
remove_media(&dir);
let mut r = Engine::default();
let res = r.execute("session.open", &json!({"path": path})).unwrap();
assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_a_session_without_audio_creates_its_folder() {
let dir = std::env::temp_dir().join(format!("soundcraft-new-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song").join("Song.scraft");
Engine::default().execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
fn tone_engine() -> (Engine, u64) {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
let track = e.session().tracks[0].id;
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let id = e.session_mut().new_clip_id();
crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800));
e.session_mut().edit.selected_tracks = vec![track];
(e, track.0)
}
#[test]
fn same_named_clips_export_as_separate_files() {
let (mut e, track) = tone_engine();
let first = e.session().tracks[0].clips()[0].id;
let src = e.session().tracks[0].clips()[0].source().unwrap();
let second = e.session_mut().new_clip_id();
crate::edit::place_clip(
e.session_mut(),
soundcraft_model::TrackId(track),
soundcraft_model::Clip::audio(second, "tone", src, 0, 10_000, 4_800),
);
for id in [first, second] {
e.session_mut().find_clip_mut(id).unwrap().name = "Take".into();
}
let dir = std::env::temp_dir().join(format!("sc-export-clips-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let n = e.execute("file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [first.0, second.0]})).unwrap();
assert_eq!(n["written"], 2);
let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
wavs.sort();
assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exports_the_selected_clips_and_refuses_an_empty_selection() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-clips-{}", std::process::id()));
let p = json!({"dir": dir.to_string_lossy()});
e.execute("edit.select_none", &json!({})).unwrap();
assert!(e.execute("file.export_clips", &p).is_err());
e.execute("edit.select", &json!({"tracks": ["Kick"], "start": {"seconds": 10.0}, "end": {"seconds": 11.0}})).unwrap();
let r = e.execute("file.export_clips", &p).unwrap();
let files = std::fs::read_dir(&dir).map_or(0, Iterator::count);
let _ = std::fs::remove_dir_all(&dir);
assert!(files >= 1 && r["written"] == files, "{r}, {files} files");
}
fn midi_engine() -> Engine {
use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence};
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
let mut s = Session::default();
let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys"));
let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums"));
let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room"));
let mut keys_seq = Sequence::default();
keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
let mut drum_seq = Sequence::default();
drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 });
drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 });
let a = s.new_clip_id();
let b = s.new_clip_id();
crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq));
crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq));
Engine::new(s)
}
fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> {
let smf = soundcraft_midi::read_smf(bytes).unwrap();
let mut out = Vec::new();
for t in &smf.tracks {
for c in &t.sequence.ctrls {
if let soundcraft_midi::CtrlKind::Cc(n) = c.kind {
out.push((t.name.clone(), n, c.value));
}
}
}
out
}
#[test]
fn midi_export_keeps_controllers_and_honours_tracks() {
let mut e = midi_engine();
let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let all = dir.join("all.mid");
e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&all).unwrap());
assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}");
assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}");
assert!(ctrls.iter().all(|(n, _, _)| n != "Room"));
let keys = dir.join("keys.mid");
e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&keys).unwrap());
assert_eq!(ctrls.len(), 1, "{ctrls:?}");
assert_eq!(ctrls[0].0, "Keys");
let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err();
assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_surround_creates_a_matching_track() {
let channels: Vec<Vec<f32>> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let mut e = Engine::default();
let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap();
assert_eq!(v["channels"], 6);
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
let src = e.session().sources[0].id;
let audio = e.session().pool.get(src).unwrap();
assert_eq!(audio.buffer.channels.len(), 6);
assert!((audio.buffer.channels[5][0] - 0.6).abs() < 1e-3);
let mut e = Engine::default();
e.execute("track.new", &json!({"name": "Bed", "format": "5.1"})).unwrap();
let id = e.session().tracks[0].id;
crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, Some(id), 0).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
assert_eq!(e.session().tracks[0].clips().len(), 1);
}
#[test]
fn repeated_consolidate_keeps_distinct_audio_files() {
let (mut e, track) = tone_engine();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let cid = e.session().tracks[0].clips()[0].id;
e.execute("clip.gain", &json!({"clip": cid.0, "db": -24.0})).unwrap();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let consolidated: Vec<_> = e.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| s.path.clone()).collect();
assert_eq!(consolidated.len(), 2, "{consolidated:?}");
assert_ne!(consolidated[0], consolidated[1]);
let dir = std::env::temp_dir().join(format!("sc-consolidate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Tone.scraft");
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let mut opened = Engine::default();
opened.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
let files: Vec<_> = opened.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| dir.join(&s.path)).collect();
assert_eq!(files.len(), 2, "{:?}", opened.session().sources);
let a = std::fs::read(&files[0]).unwrap();
let b = std::fs::read(&files[1]).unwrap();
assert_ne!(a, b, "the second consolidation replaced the first file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_copy_leaves_the_open_session_alone() {
let (mut e, _) = tone_engine();
let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("Original.scraft");
e.execute("session.save_as", &json!({"path": original.to_string_lossy()})).unwrap();
e.execute("track.new", &json!({"count": 1, "name": "Extra", "format": "mono"})).unwrap();
assert!(e.is_dirty());
let copy_dir = dir.join("copy");
std::fs::create_dir_all(&copy_dir).unwrap();
let copy = copy_dir.join("Copy.scraft");
e.execute("session.save_copy", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(e.path.as_deref(), Some(original.to_string_lossy().as_ref()));
assert_eq!(e.session().name, "Original");
assert!(e.is_dirty());
assert_eq!(e.session().tracks.len(), 2);
let mut from_original = Engine::default();
from_original.execute("session.open", &json!({"path": original.to_string_lossy()})).unwrap();
assert_eq!(from_original.session().tracks.len(), 1);
let mut from_copy = Engine::default();
from_copy.execute("session.open", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(from_copy.session().tracks.len(), 2);
assert!(from_copy.session().track_by_name("Extra").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}
mod tests;
+403
View File
@@ -0,0 +1,403 @@
use super::*;
use crate::Engine;
use soundcraft_audio_io::{AudioBuffer, EncodeOptions};
use soundcraft_model::ChannelFormat;
fn tone_wav(dir: &std::path::Path, name: &str) -> String {
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.1).sin() * 0.2).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
/// Issue #597: the app decodes on a worker thread and hands the audio over; the command then
/// only places it. Audio decoded for another rate is ignored and the file is read again.
#[test]
fn import_audio_places_audio_offered_by_a_worker() {
let dir = std::env::temp_dir().join(format!("sc-import-offered-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
let mut e = Engine::default();
let rate = e.session().sample_rate.hz();
let decoded = std::thread::spawn({
let wav = wav.clone();
move || crate::io::decode_import(&wav, rate)
})
.join()
.unwrap()
.unwrap();
assert_eq!(decoded.frames(), 4_800);
e.offer_import(decoded);
// The file changes after decoding: the offered audio is placed, not the new bytes.
std::fs::write(&wav, b"not audio").unwrap();
let r = e.execute("file.import_audio", &json!({"path": wav, "at": 960})).unwrap();
assert_eq!(r["imported"][0]["frames"], 4_800);
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].clips()[0].start, 960);
assert_eq!(e.session().sources[0].path, wav);
// Taken once: the next import reads the (now broken) file.
assert!(e.execute("file.import_audio", &json!({"path": wav})).is_err());
// Decoded for another rate: ignored, so the broken file is read and refused.
let good = tone_wav(&dir, "good.wav");
let other = crate::io::decode_import(&good, rate / 2).unwrap();
std::fs::write(&good, b"not audio").unwrap();
e.offer_import(other);
assert!(e.execute("file.import_audio", &json!({"path": good})).is_err());
e.drop_offered_imports();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn export_session_text_needs_replace_to_overwrite() {
let dir = std::env::temp_dir().join(format!("sc-export-text-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("info.txt");
let path_str = path.to_string_lossy().into_owned();
let mut e = Engine::default();
e.execute("file.export_session_text", &json!({"path": path_str})).unwrap();
std::fs::write(&path, "keep me").unwrap();
let err = e.execute("file.export_session_text", &json!({"path": path_str})).unwrap_err();
assert!(err.to_string().contains("replace: true"), "{err}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "keep me");
e.execute("file.export_session_text", &json!({"path": path_str, "replace": true})).unwrap();
assert_ne!(std::fs::read_to_string(&path).unwrap(), "keep me");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_audio_honours_new_tracks() {
let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
// Default / new_tracks:true: create a new track even when one is selected.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
let target = e.session().tracks[0].id;
e.session_mut().edit.selected_tracks = vec![target];
e.execute("file.import_audio", &json!({"path": wav, "new_tracks": true})).unwrap();
assert_eq!(e.session().tracks.len(), 2);
assert_eq!(e.session().track(target).unwrap().clips().len(), 0);
assert_eq!(e.session().tracks[1].clips().len(), 1);
// new_tracks:false with a selected audio track: import onto it.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
let target = e.session().tracks[0].id;
e.session_mut().edit.selected_tracks = vec![target];
e.execute("file.import_audio", &json!({"path": wav, "new_tracks": false})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().track(target).unwrap().clips().len(), 1);
// new_tracks:false with no selection and no track: clear error.
let mut e = Engine::default();
let err = e.execute("file.import_audio", &json!({"path": wav, "new_tracks": false})).unwrap_err();
assert!(err.to_string().contains("new_tracks: false"), "{err}");
// Explicit track wins over new_tracks:true.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
e.execute("file.import_audio", &json!({"path": wav, "track": "Target", "new_tracks": true})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].clips().len(), 1);
// Omitted new_tracks still creates a track on a blank session.
let mut e = Engine::default();
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].kind, TrackKind::Audio);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Mono);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn bounce_refuses_formats_it_cannot_write() {
let mut e = Engine::default();
let dir = std::env::temp_dir().join(format!("sc-bounce-ext-{}", std::process::id()));
assert!(std::fs::create_dir_all(&dir).is_ok());
for name in ["mix.mp3", "mix.ogg", "mix.xyz"] {
let path = dir.join(name);
let err = e.execute("file.bounce_mix", &json!({"path": path.to_string_lossy(), "start": 0, "end": 480})).map(|_| ()).unwrap_err();
assert!(err.to_string().contains("supported: wav"), "{name}: {err}");
assert!(!path.exists(), "{name} must not be written");
}
let ok = dir.join("mix.flac");
assert!(e.execute("file.bounce_mix", &json!({"path": ok.to_string_lossy(), "start": 0, "end": 480})).is_ok());
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
#[test]
fn bounce_source_renders_the_named_bus() {
let dir = std::env::temp_dir().join(format!("sc-bounce-source-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
let mut e = Engine::default();
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
let stem = e.session_mut().add_bus("Stem", ChannelFormat::Stereo);
e.session_mut().add_bus("Empty", ChannelFormat::Stereo);
let track = e.session().tracks[0].id;
{
let tr = e.session_mut().track_mut(track).unwrap();
let mut send = soundcraft_model::SendSlot::new(soundcraft_model::Route::Bus(stem));
send.level_db = -12.0;
tr.mixer.sends[0] = Some(send);
}
let bounce = |e: &mut Engine, name: &str, source: Option<&str>| {
let path = dir.join(name);
let mut params = json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false});
if let Some(source) = source {
params["source"] = json!(source);
}
let value = e.execute("file.bounce_mix", &params).unwrap();
let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
(std::fs::read(&path).unwrap(), peak)
};
let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None);
let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main"));
let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM"));
let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty"));
assert_eq!(main_bytes, named_main);
assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}");
assert!(main_peak > -20.0, "{main_peak}");
assert_ne!(stem_bytes, main_bytes);
assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}");
assert_ne!(empty_bytes, main_bytes);
assert!(empty_peak < -100.0, "{empty_peak}");
let missing = dir.join("missing.wav");
let err = e
.execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false}))
.unwrap_err();
assert!(err.to_string().contains("unknown bus"), "{err}");
assert!(!missing.exists());
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
fn remove_media(dir: &std::path::Path) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = entry.path();
if p.is_dir() {
remove_media(&p);
} else if p.extension().is_some_and(|x| x != "scraft") {
let _ = std::fs::remove_file(&p);
}
}
}
#[test]
fn session_open_reports_missing_media() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-open-missing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Gone.scraft").to_string_lossy().into_owned();
e.execute("session.save_as", &json!({"path": path})).unwrap();
let mut ok = Engine::default();
assert!(ok.execute("session.open", &json!({"path": path})).unwrap()["missing"].as_array().is_some_and(Vec::is_empty));
remove_media(&dir);
let mut r = Engine::default();
let res = r.execute("session.open", &json!({"path": path})).unwrap();
assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_a_session_without_audio_creates_its_folder() {
let dir = std::env::temp_dir().join(format!("soundcraft-new-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song").join("Song.scraft");
Engine::default().execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
fn tone_engine() -> (Engine, u64) {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
let track = e.session().tracks[0].id;
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let id = e.session_mut().new_clip_id();
crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800));
e.session_mut().edit.selected_tracks = vec![track];
(e, track.0)
}
#[test]
fn same_named_clips_export_as_separate_files() {
let (mut e, track) = tone_engine();
let first = e.session().tracks[0].clips()[0].id;
let src = e.session().tracks[0].clips()[0].source().unwrap();
let second = e.session_mut().new_clip_id();
crate::edit::place_clip(e.session_mut(), soundcraft_model::TrackId(track), soundcraft_model::Clip::audio(second, "tone", src, 0, 10_000, 4_800));
for id in [first, second] {
e.session_mut().find_clip_mut(id).unwrap().name = "Take".into();
}
let dir = std::env::temp_dir().join(format!("sc-export-clips-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let n = e.execute("file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [first.0, second.0]})).unwrap();
assert_eq!(n["written"], 2);
let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
wavs.sort();
assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exports_the_selected_clips_and_refuses_an_empty_selection() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-clips-{}", std::process::id()));
let p = json!({"dir": dir.to_string_lossy()});
e.execute("edit.select_none", &json!({})).unwrap();
assert!(e.execute("file.export_clips", &p).is_err());
e.execute("edit.select", &json!({"tracks": ["Kick"], "start": {"seconds": 10.0}, "end": {"seconds": 11.0}})).unwrap();
let r = e.execute("file.export_clips", &p).unwrap();
let files = std::fs::read_dir(&dir).map_or(0, Iterator::count);
let _ = std::fs::remove_dir_all(&dir);
assert!(files >= 1 && r["written"] == files, "{r}, {files} files");
}
fn midi_engine() -> Engine {
use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence};
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
let mut s = Session::default();
let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys"));
let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums"));
let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room"));
let mut keys_seq = Sequence::default();
keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
let mut drum_seq = Sequence::default();
drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 });
drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 });
let a = s.new_clip_id();
let b = s.new_clip_id();
crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq));
crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq));
Engine::new(s)
}
fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> {
let smf = soundcraft_midi::read_smf(bytes).unwrap();
let mut out = Vec::new();
for t in &smf.tracks {
for c in &t.sequence.ctrls {
if let soundcraft_midi::CtrlKind::Cc(n) = c.kind {
out.push((t.name.clone(), n, c.value));
}
}
}
out
}
#[test]
fn midi_export_keeps_controllers_and_honours_tracks() {
let mut e = midi_engine();
let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let all = dir.join("all.mid");
e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&all).unwrap());
assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}");
assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}");
assert!(ctrls.iter().all(|(n, _, _)| n != "Room"));
let keys = dir.join("keys.mid");
e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&keys).unwrap());
assert_eq!(ctrls.len(), 1, "{ctrls:?}");
assert_eq!(ctrls[0].0, "Keys");
let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err();
assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_surround_creates_a_matching_track() {
let channels: Vec<Vec<f32>> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let mut e = Engine::default();
let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap();
assert_eq!(v["channels"], 6);
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
let src = e.session().sources[0].id;
let audio = e.session().pool.get(src).unwrap();
assert_eq!(audio.buffer.channels.len(), 6);
assert!((audio.buffer.channels[5][0] - 0.6).abs() < 1e-3);
let mut e = Engine::default();
e.execute("track.new", &json!({"name": "Bed", "format": "5.1"})).unwrap();
let id = e.session().tracks[0].id;
crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, Some(id), 0).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
assert_eq!(e.session().tracks[0].clips().len(), 1);
}
#[test]
fn repeated_consolidate_keeps_distinct_audio_files() {
let (mut e, track) = tone_engine();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let cid = e.session().tracks[0].clips()[0].id;
e.execute("clip.gain", &json!({"clip": cid.0, "db": -24.0})).unwrap();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let consolidated: Vec<_> = e.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| s.path.clone()).collect();
assert_eq!(consolidated.len(), 2, "{consolidated:?}");
assert_ne!(consolidated[0], consolidated[1]);
let dir = std::env::temp_dir().join(format!("sc-consolidate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Tone.scraft");
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let mut opened = Engine::default();
opened.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
let files: Vec<_> = opened.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| dir.join(&s.path)).collect();
assert_eq!(files.len(), 2, "{:?}", opened.session().sources);
let a = std::fs::read(&files[0]).unwrap();
let b = std::fs::read(&files[1]).unwrap();
assert_ne!(a, b, "the second consolidation replaced the first file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_copy_leaves_the_open_session_alone() {
let (mut e, _) = tone_engine();
let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("Original.scraft");
e.execute("session.save_as", &json!({"path": original.to_string_lossy()})).unwrap();
e.execute("track.new", &json!({"count": 1, "name": "Extra", "format": "mono"})).unwrap();
assert!(e.is_dirty());
let copy_dir = dir.join("copy");
std::fs::create_dir_all(&copy_dir).unwrap();
let copy = copy_dir.join("Copy.scraft");
e.execute("session.save_copy", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(e.path.as_deref(), Some(original.to_string_lossy().as_ref()));
assert_eq!(e.session().name, "Original");
assert!(e.is_dirty());
assert_eq!(e.session().tracks.len(), 2);
let mut from_original = Engine::default();
from_original.execute("session.open", &json!({"path": original.to_string_lossy()})).unwrap();
assert_eq!(from_original.session().tracks.len(), 1);
let mut from_copy = Engine::default();
from_copy.execute("session.open", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(from_copy.session().tracks.len(), 2);
assert!(from_copy.session().track_by_name("Extra").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
+1 -350
View File
@@ -769,353 +769,4 @@ fn copy_to_send(e: &mut Engine, p: &Value) -> Result<Value> {
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
use soundcraft_time::Range;
use std::sync::Arc;
fn rms_db(before: &[f32], after: &[f32]) -> f32 {
let energy = |xs: &[f32]| xs.iter().map(|x| x * x).sum::<f32>() / xs.len().max(1) as f32;
10.0 * (energy(after).max(1e-20) / energy(before).max(1e-20)).log10()
}
fn tone_session(points: &[(i64, f32)]) -> (Engine, TrackId) {
let mut s = Session::default();
let n = 8192;
let tone: Vec<f32> = (0..n).map(|i| (i as f32 * 0.07).sin() * 0.25).collect();
s.pool.insert(SourceId(910), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![tone] })));
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
let id = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "tone", SourceId(910), 0, 0, n));
s.track_mut(t).unwrap().mixer.volume_db = -3.0;
for &(at, value) in points {
s.track_mut(t).unwrap().lane_mut(&AutoParam::Volume).set_point(at, value);
}
s.edit.selected_tracks = vec![t];
s.edit.selection = Range::new(2048, 6144);
(Engine::new(s), t)
}
fn rendered(s: &Session) -> Vec<f32> {
soundcraft_mix::render_range(s, Range::new(0, 8192), 1024).into_iter().next().unwrap_or_default()
}
fn convert(e: &mut Engine, t: TrackId, start: i64, end: i64) {
e.session_mut().edit.selection = Range::new(start, end);
e.execute("automation.volume_to_clip_gain", &json!({"track": t.0, "start": start, "end": end})).unwrap();
}
#[test]
fn partial_volume_to_clip_gain_keeps_each_region() {
let points = [(0, -6.0), (2048, -6.0), (4096, -6.0), (6144, -6.0), (8192, -6.0)];
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2048), (2048, 6144), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.02, "constant {start}..{end} changed by {delta} dB");
}
let tr = e.session().track(t).unwrap();
assert!((tr.mixer.volume_db + 3.0).abs() < 1e-5);
let lane = tr.lane(&AutoParam::Volume).unwrap();
assert!((lane.value_at(100, 0.0) + 6.0).abs() < 1e-3);
assert!((lane.value_at(3000, 0.0) + 3.0).abs() < 1e-3);
assert!((lane.value_at(7000, 0.0) + 6.0).abs() < 1e-3);
let (mut e, t) = tone_session(&[]);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(diff < 1e-5, "no lane changed a sample by {diff}");
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 0, 8193);
let after = rendered(e.session());
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(diff < 1e-4, "full selection changed a sample by {diff}");
assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none_or(|l| l.points.is_empty()));
for points in [
[(0, -12.0), (2048, -9.0), (4096, -6.0), (6144, -3.0), (8192, 0.0)],
[(0, -12.0), (2048, -6.0), (4096, -12.0), (6144, -6.0), (8192, -12.0)],
] {
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2048), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.02, "outside {start}..{end} changed by {delta} dB for {points:?}");
}
let delta = rms_db(&before[2048..6144], &after[2048..6144]);
assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}");
}
}
#[test]
fn lane_points_all_inside_the_selection_keep_the_outside_level() {
let (mut e, t) = tone_session(&[(2048, -9.0), (6143, -9.0)]);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2000), (2048, 6144), (6200, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.05, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn set_point_from_drops_the_points_in_between() {
let (mut e, t) = tone_session(&[(100, -20.0), (150, -20.0), (200, -20.0), (250, -20.0), (300, -20.0)]);
e.execute("automation.set_point", &json!({"track": t.0, "from": 100, "at": 250, "value": 0.0})).unwrap();
// A loop wrap: `from` lies after `at`, so nothing goes.
e.execute("automation.set_point", &json!({"track": t.0, "from": 400, "at": 50, "value": -6.0})).unwrap();
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
let points: Vec<(i64, f32)> = lane.points.iter().map(|p| (p.at, p.value)).collect();
assert_eq!(points, [(50, -6.0), (100, -20.0), (250, 0.0), (300, -20.0)]);
}
#[test]
fn clip_gain_points_inside_the_selection_are_kept() {
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
let clip = e.session().track(t).unwrap().clips()[0].id;
e.session_mut().find_clip_mut(clip).unwrap().gain_env = vec![(3000, 0.0), (4000, -20.0), (5000, 0.0)];
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(2048, 3000), (3500, 4500), (5000, 6144)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_keeps_the_existing_lane() {
let clip_gain_to_volume = |points: &[(i64, f32)], env: Vec<(i64, f32)>| {
let (mut e, t) = tone_session(points);
let clip = e.session().track(t).unwrap().clips()[0].id;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.gain_db = 6.0;
c.gain_env = env;
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
let after = rendered(e.session());
assert!(e.session().find_clip(clip).unwrap().1.gain_db == 0.0);
(before, after)
};
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], Vec::new());
let delta = rms_db(&before, &after);
assert!(delta.abs() < 0.02, "level changed by {delta} dB");
// A moving lane keeps its shape, and its points inside the clip carry the gain.
let (before, after) = clip_gain_to_volume(&[(0, -12.0), (4096, -6.0), (8192, -12.0)], Vec::new());
for (start, end) in [(0, 2048), (2048, 4096), (4096, 6144), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
}
// The clip's own gain envelope rides on the lane value as well.
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], vec![(2000, 0.0), (6000, -10.0)]);
for (start, end) in [(3900, 4100), (5000, 5200)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_holds_the_envelope_ends_out_to_the_clip_edges() {
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
let clip = e.session().track(t).unwrap().clips()[0].id;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.gain_db = 6.0;
c.gain_env = vec![(2000, 0.0), (6000, -10.0)];
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
let after = rendered(e.session());
for (start, end) in [(0, 1000), (1000, 2000), (6000, 7000), (7000, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_leaves_audio_outside_the_clip_alone() {
// Three clips in a row; only the middle one is converted, and the lane extends past it on both sides.
let (mut e, t) = tone_session(&[(0, -6.0), (16384, -6.0)]);
let (middle, last) = (e.session_mut().new_clip_id(), e.session_mut().new_clip_id());
let pl = e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap();
if let Some(c) = pl.clips.first_mut() {
c.length = 2048;
}
pl.clips.push(Clip::audio(middle, "mid", SourceId(910), 2048, 2048, 4096));
pl.clips.push(Clip::audio(last, "end", SourceId(910), 6144, 6144, 2048));
e.session_mut().find_clip_mut(middle).unwrap().gain_db = 6.0;
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 2048, "end": 6144})).unwrap();
let after = rendered(e.session());
assert!(e.session().find_clip(middle).unwrap().1.gain_db == 0.0);
for (start, end) in [(0, 2040), (2056, 6136), (6152, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn insert_params_rejects_unknown_and_non_numeric_keys() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let t = e.session().tracks[0].id.0;
e.execute("mix.insert", &json!({"track": t, "slot": 0, "plugin": "gain"})).unwrap();
fn gain(e: &Engine) -> f32 {
e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().params["gain"]
}
// A typo names no parameter: the call fails and says which key.
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain_db": -14.0}})).unwrap_err();
assert!(err.to_string().contains("gain_db"), "{err}");
// Mixed keys fail as a whole and change nothing.
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0, "width": 1.0}})).unwrap_err();
assert!(err.to_string().contains("width"), "{err}");
assert_eq!(gain(&e), 0.0);
// Non-numeric and non-finite values are rejected too.
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": "loud"}})).is_err());
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": null}})).is_err());
assert_eq!(gain(&e), 0.0);
// Correct keys still apply, clamped to the parameter range.
let r = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0}})).unwrap();
assert_eq!(r["set"], 1);
assert_eq!(gain(&e), -14.0);
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": 999.0}})).unwrap();
assert_eq!(gain(&e), 24.0);
// Empty params with a preset name still recall (the rename path).
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {}, "preset": "Saved"})).unwrap();
assert_eq!(e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().preset, "Saved");
}
#[test]
fn copy_to_send_rejects_an_invalid_send_slot() {
let mut e = crate::demo::demo_engine();
let history = e.undo_history().len();
let lanes = |e: &Engine| e.session().tracks.iter().filter(|t| t.lane(&AutoParam::SendLevel(0)).is_some_and(|l| !l.points.is_empty())).count();
let before = lanes(&e);
for bad_params in [json!({"track": "Kick", "send": 99}), json!({"track": "Kick", "send": "zz"})] {
let r = e.execute("automation.copy_to_send", &bad_params);
assert!(r.is_err(), "{bad_params} accepted: {r:?}");
assert_eq!(e.undo_history().len(), history, "{bad_params} pushed an undo step");
assert_eq!(lanes(&e), before, "{bad_params} edited Send A");
}
// No `send` (the menu item's call) still means Send A.
e.execute("automation.copy_to_send", &json!({"track": "Kick"})).unwrap();
assert_eq!(lanes(&e), before + 1);
// A valid slot still copies.
e.execute("automation.copy_to_send", &json!({"track": "Kick", "send": "a"})).unwrap();
assert_eq!(lanes(&e), before + 1);
}
#[test]
fn retargeting_a_send_keeps_its_settings() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
assert_eq!(sl.target, soundcraft_model::Route::Bus(delay));
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
}
fn grouped_demo() -> Engine {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &json!({"tracks": [3], "db": 0.0})).unwrap();
e.execute("mix.volume", &json!({"tracks": [6], "db": 0.0})).unwrap();
e.execute("track.group", &json!({"tracks": [3, 6]})).unwrap();
e
}
fn volumes(e: &Engine) -> Vec<f32> {
[3, 6].iter().map(|id| e.session().track(TrackId(*id)).unwrap().mixer.volume_db).collect()
}
#[test]
fn volume_delta_on_a_whole_group_applies_once() {
let mut e = grouped_demo();
e.execute("mix.volume", &json!({"tracks": [3, 6], "delta_db": -3.0})).unwrap();
assert_eq!(volumes(&e), [-3.0, -3.0]);
let mut e = grouped_demo();
e.execute("mix.volume", &json!({"tracks": [3], "delta_db": -3.0})).unwrap();
assert_eq!(volumes(&e), [-3.0, -3.0]);
}
#[test]
fn volume_absolute_on_a_whole_group_keeps_offsets() {
let mut e = grouped_demo();
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = -2.0;
e.execute("mix.volume", &json!({"tracks": [3, 6], "db": -10.0})).unwrap();
assert_eq!(volumes(&e), [-10.0, -12.0]);
}
#[test]
fn clear_with_unknown_param_keeps_lanes() {
let (mut e, t) = tone_session(&[(0, -6.0), (100, -3.0)]);
e.session_mut().track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0)).set_point(0, 0.5);
e.session_mut().edit.selection = Range::new(0, 0);
let count = |e: &Engine| e.session().track(t).unwrap().automation.iter().map(|l| l.points.len()).sum::<usize>();
let r = e.execute("automation.clear", &json!({"track": t.0, "param": "volum"}));
assert!(r.is_err(), "unknown param succeeded: {r:?}");
assert_eq!(count(&e), 3, "unknown param changed the lanes");
e.execute("automation.clear", &json!({"track": t.0, "param": "volume"})).unwrap();
assert_eq!(count(&e), 1, "named param cleared the wrong lanes");
e.execute("automation.clear", &json!({"track": t.0})).unwrap();
assert_eq!(count(&e), 0, "absent param must still clear every lane");
}
#[test]
fn send_rejects_unknown_bus() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err();
assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}");
assert_eq!(e.session().busses.len(), 1);
assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none));
e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap();
assert_eq!(e.session().busses.len(), 1);
}
#[test]
fn insert_and_send_reject_invalid_slot() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
for slot in [json!(12), json!("k"), json!("x1")] {
let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"}));
assert!(r.is_err(), "insert slot {slot} accepted: {r:?}");
}
for slot in [json!(12), json!("k")] {
let r = e.execute("mix.send", &json!({"track": "Gtr", "slot": slot, "bus": "Verb"}));
assert!(r.is_err(), "send slot {slot} accepted: {r:?}");
}
let mixer = &e.session().tracks[0].mixer;
assert!(mixer.inserts.iter().all(Option::is_none));
assert!(mixer.sends.iter().all(Option::is_none));
// Leaving `slot` out still picks the first free slot.
let r = e.execute("mix.insert", &json!({"track": "Gtr", "plugin": "compressor"})).unwrap();
assert_eq!(r["slot"], 0);
let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
assert_eq!(r["slot"], 0);
}
}
mod tests;
+347
View File
@@ -0,0 +1,347 @@
use super::*;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
use soundcraft_time::Range;
use std::sync::Arc;
fn rms_db(before: &[f32], after: &[f32]) -> f32 {
let energy = |xs: &[f32]| xs.iter().map(|x| x * x).sum::<f32>() / xs.len().max(1) as f32;
10.0 * (energy(after).max(1e-20) / energy(before).max(1e-20)).log10()
}
fn tone_session(points: &[(i64, f32)]) -> (Engine, TrackId) {
let mut s = Session::default();
let n = 8192;
let tone: Vec<f32> = (0..n).map(|i| (i as f32 * 0.07).sin() * 0.25).collect();
s.pool.insert(SourceId(910), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![tone] })));
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
let id = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "tone", SourceId(910), 0, 0, n));
s.track_mut(t).unwrap().mixer.volume_db = -3.0;
for &(at, value) in points {
s.track_mut(t).unwrap().lane_mut(&AutoParam::Volume).set_point(at, value);
}
s.edit.selected_tracks = vec![t];
s.edit.selection = Range::new(2048, 6144);
(Engine::new(s), t)
}
fn rendered(s: &Session) -> Vec<f32> {
soundcraft_mix::render_range(s, Range::new(0, 8192), 1024).into_iter().next().unwrap_or_default()
}
fn convert(e: &mut Engine, t: TrackId, start: i64, end: i64) {
e.session_mut().edit.selection = Range::new(start, end);
e.execute("automation.volume_to_clip_gain", &json!({"track": t.0, "start": start, "end": end})).unwrap();
}
#[test]
fn partial_volume_to_clip_gain_keeps_each_region() {
let points = [(0, -6.0), (2048, -6.0), (4096, -6.0), (6144, -6.0), (8192, -6.0)];
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2048), (2048, 6144), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.02, "constant {start}..{end} changed by {delta} dB");
}
let tr = e.session().track(t).unwrap();
assert!((tr.mixer.volume_db + 3.0).abs() < 1e-5);
let lane = tr.lane(&AutoParam::Volume).unwrap();
assert!((lane.value_at(100, 0.0) + 6.0).abs() < 1e-3);
assert!((lane.value_at(3000, 0.0) + 3.0).abs() < 1e-3);
assert!((lane.value_at(7000, 0.0) + 6.0).abs() < 1e-3);
let (mut e, t) = tone_session(&[]);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(diff < 1e-5, "no lane changed a sample by {diff}");
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 0, 8193);
let after = rendered(e.session());
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(diff < 1e-4, "full selection changed a sample by {diff}");
assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none_or(|l| l.points.is_empty()));
for points in
[[(0, -12.0), (2048, -9.0), (4096, -6.0), (6144, -3.0), (8192, 0.0)], [(0, -12.0), (2048, -6.0), (4096, -12.0), (6144, -6.0), (8192, -12.0)]]
{
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2048), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.02, "outside {start}..{end} changed by {delta} dB for {points:?}");
}
let delta = rms_db(&before[2048..6144], &after[2048..6144]);
assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}");
}
}
#[test]
fn lane_points_all_inside_the_selection_keep_the_outside_level() {
let (mut e, t) = tone_session(&[(2048, -9.0), (6143, -9.0)]);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2000), (2048, 6144), (6200, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.05, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn set_point_from_drops_the_points_in_between() {
let (mut e, t) = tone_session(&[(100, -20.0), (150, -20.0), (200, -20.0), (250, -20.0), (300, -20.0)]);
e.execute("automation.set_point", &json!({"track": t.0, "from": 100, "at": 250, "value": 0.0})).unwrap();
// A loop wrap: `from` lies after `at`, so nothing goes.
e.execute("automation.set_point", &json!({"track": t.0, "from": 400, "at": 50, "value": -6.0})).unwrap();
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
let points: Vec<(i64, f32)> = lane.points.iter().map(|p| (p.at, p.value)).collect();
assert_eq!(points, [(50, -6.0), (100, -20.0), (250, 0.0), (300, -20.0)]);
}
#[test]
fn clip_gain_points_inside_the_selection_are_kept() {
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
let clip = e.session().track(t).unwrap().clips()[0].id;
e.session_mut().find_clip_mut(clip).unwrap().gain_env = vec![(3000, 0.0), (4000, -20.0), (5000, 0.0)];
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(2048, 3000), (3500, 4500), (5000, 6144)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_keeps_the_existing_lane() {
let clip_gain_to_volume = |points: &[(i64, f32)], env: Vec<(i64, f32)>| {
let (mut e, t) = tone_session(points);
let clip = e.session().track(t).unwrap().clips()[0].id;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.gain_db = 6.0;
c.gain_env = env;
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
let after = rendered(e.session());
assert!(e.session().find_clip(clip).unwrap().1.gain_db == 0.0);
(before, after)
};
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], Vec::new());
let delta = rms_db(&before, &after);
assert!(delta.abs() < 0.02, "level changed by {delta} dB");
// A moving lane keeps its shape, and its points inside the clip carry the gain.
let (before, after) = clip_gain_to_volume(&[(0, -12.0), (4096, -6.0), (8192, -12.0)], Vec::new());
for (start, end) in [(0, 2048), (2048, 4096), (4096, 6144), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
}
// The clip's own gain envelope rides on the lane value as well.
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], vec![(2000, 0.0), (6000, -10.0)]);
for (start, end) in [(3900, 4100), (5000, 5200)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_holds_the_envelope_ends_out_to_the_clip_edges() {
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
let clip = e.session().track(t).unwrap().clips()[0].id;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.gain_db = 6.0;
c.gain_env = vec![(2000, 0.0), (6000, -10.0)];
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
let after = rendered(e.session());
for (start, end) in [(0, 1000), (1000, 2000), (6000, 7000), (7000, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_leaves_audio_outside_the_clip_alone() {
// Three clips in a row; only the middle one is converted, and the lane extends past it on both sides.
let (mut e, t) = tone_session(&[(0, -6.0), (16384, -6.0)]);
let (middle, last) = (e.session_mut().new_clip_id(), e.session_mut().new_clip_id());
let pl = e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap();
if let Some(c) = pl.clips.first_mut() {
c.length = 2048;
}
pl.clips.push(Clip::audio(middle, "mid", SourceId(910), 2048, 2048, 4096));
pl.clips.push(Clip::audio(last, "end", SourceId(910), 6144, 6144, 2048));
e.session_mut().find_clip_mut(middle).unwrap().gain_db = 6.0;
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 2048, "end": 6144})).unwrap();
let after = rendered(e.session());
assert!(e.session().find_clip(middle).unwrap().1.gain_db == 0.0);
for (start, end) in [(0, 2040), (2056, 6136), (6152, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn insert_params_rejects_unknown_and_non_numeric_keys() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let t = e.session().tracks[0].id.0;
e.execute("mix.insert", &json!({"track": t, "slot": 0, "plugin": "gain"})).unwrap();
fn gain(e: &Engine) -> f32 {
e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().params["gain"]
}
// A typo names no parameter: the call fails and says which key.
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain_db": -14.0}})).unwrap_err();
assert!(err.to_string().contains("gain_db"), "{err}");
// Mixed keys fail as a whole and change nothing.
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0, "width": 1.0}})).unwrap_err();
assert!(err.to_string().contains("width"), "{err}");
assert_eq!(gain(&e), 0.0);
// Non-numeric and non-finite values are rejected too.
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": "loud"}})).is_err());
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": null}})).is_err());
assert_eq!(gain(&e), 0.0);
// Correct keys still apply, clamped to the parameter range.
let r = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0}})).unwrap();
assert_eq!(r["set"], 1);
assert_eq!(gain(&e), -14.0);
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": 999.0}})).unwrap();
assert_eq!(gain(&e), 24.0);
// Empty params with a preset name still recall (the rename path).
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {}, "preset": "Saved"})).unwrap();
assert_eq!(e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().preset, "Saved");
}
#[test]
fn copy_to_send_rejects_an_invalid_send_slot() {
let mut e = crate::demo::demo_engine();
let history = e.undo_history().len();
let lanes = |e: &Engine| e.session().tracks.iter().filter(|t| t.lane(&AutoParam::SendLevel(0)).is_some_and(|l| !l.points.is_empty())).count();
let before = lanes(&e);
for bad_params in [json!({"track": "Kick", "send": 99}), json!({"track": "Kick", "send": "zz"})] {
let r = e.execute("automation.copy_to_send", &bad_params);
assert!(r.is_err(), "{bad_params} accepted: {r:?}");
assert_eq!(e.undo_history().len(), history, "{bad_params} pushed an undo step");
assert_eq!(lanes(&e), before, "{bad_params} edited Send A");
}
// No `send` (the menu item's call) still means Send A.
e.execute("automation.copy_to_send", &json!({"track": "Kick"})).unwrap();
assert_eq!(lanes(&e), before + 1);
// A valid slot still copies.
e.execute("automation.copy_to_send", &json!({"track": "Kick", "send": "a"})).unwrap();
assert_eq!(lanes(&e), before + 1);
}
#[test]
fn retargeting_a_send_keeps_its_settings() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
assert_eq!(sl.target, soundcraft_model::Route::Bus(delay));
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
}
fn grouped_demo() -> Engine {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &json!({"tracks": [3], "db": 0.0})).unwrap();
e.execute("mix.volume", &json!({"tracks": [6], "db": 0.0})).unwrap();
e.execute("track.group", &json!({"tracks": [3, 6]})).unwrap();
e
}
fn volumes(e: &Engine) -> Vec<f32> {
[3, 6].iter().map(|id| e.session().track(TrackId(*id)).unwrap().mixer.volume_db).collect()
}
#[test]
fn volume_delta_on_a_whole_group_applies_once() {
let mut e = grouped_demo();
e.execute("mix.volume", &json!({"tracks": [3, 6], "delta_db": -3.0})).unwrap();
assert_eq!(volumes(&e), [-3.0, -3.0]);
let mut e = grouped_demo();
e.execute("mix.volume", &json!({"tracks": [3], "delta_db": -3.0})).unwrap();
assert_eq!(volumes(&e), [-3.0, -3.0]);
}
#[test]
fn volume_absolute_on_a_whole_group_keeps_offsets() {
let mut e = grouped_demo();
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = -2.0;
e.execute("mix.volume", &json!({"tracks": [3, 6], "db": -10.0})).unwrap();
assert_eq!(volumes(&e), [-10.0, -12.0]);
}
#[test]
fn clear_with_unknown_param_keeps_lanes() {
let (mut e, t) = tone_session(&[(0, -6.0), (100, -3.0)]);
e.session_mut().track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0)).set_point(0, 0.5);
e.session_mut().edit.selection = Range::new(0, 0);
let count = |e: &Engine| e.session().track(t).unwrap().automation.iter().map(|l| l.points.len()).sum::<usize>();
let r = e.execute("automation.clear", &json!({"track": t.0, "param": "volum"}));
assert!(r.is_err(), "unknown param succeeded: {r:?}");
assert_eq!(count(&e), 3, "unknown param changed the lanes");
e.execute("automation.clear", &json!({"track": t.0, "param": "volume"})).unwrap();
assert_eq!(count(&e), 1, "named param cleared the wrong lanes");
e.execute("automation.clear", &json!({"track": t.0})).unwrap();
assert_eq!(count(&e), 0, "absent param must still clear every lane");
}
#[test]
fn send_rejects_unknown_bus() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err();
assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}");
assert_eq!(e.session().busses.len(), 1);
assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none));
e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap();
assert_eq!(e.session().busses.len(), 1);
}
#[test]
fn insert_and_send_reject_invalid_slot() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
for slot in [json!(12), json!("k"), json!("x1")] {
let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"}));
assert!(r.is_err(), "insert slot {slot} accepted: {r:?}");
}
for slot in [json!(12), json!("k")] {
let r = e.execute("mix.send", &json!({"track": "Gtr", "slot": slot, "bus": "Verb"}));
assert!(r.is_err(), "send slot {slot} accepted: {r:?}");
}
let mixer = &e.session().tracks[0].mixer;
assert!(mixer.inserts.iter().all(Option::is_none));
assert!(mixer.sends.iter().all(Option::is_none));
// Leaving `slot` out still picks the first free slot.
let r = e.execute("mix.insert", &json!({"track": "Gtr", "plugin": "compressor"})).unwrap();
assert_eq!(r["slot"], 0);
let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
assert_eq!(r["slot"], 0);
}
+2 -1
View File
@@ -232,7 +232,8 @@ mod tests {
let buf = AudioBuffer { sample_rate: 96_000, channels: vec![tone.clone(), tone] };
let mut e = Engine::default();
let t = instrument_track(&mut e);
for (ext, format) in [("flac", FileFormat::Flac), ("aif", FileFormat::Aiff)] {
for format in [FileFormat::Flac, FileFormat::Aiff] {
let ext = format.default_extension();
let path = d.join(format!("tone.{ext}"));
let opts = EncodeOptions { format, bit_depth: BitDepth::Int24, dither: false, bwf: None };
std::fs::write(&path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
+3 -3
View File
@@ -165,7 +165,7 @@ mod tests {
let sess = dir.join("s.scraft");
e.execute("session.save_as", &json!({"path": sess.to_string_lossy()})).unwrap();
let mut e2 = Engine::default();
let missing = crate::io::open_session(&mut e2, &sess.to_string_lossy()).unwrap();
let missing = crate::io::open_session(&mut e2, &sess).unwrap();
assert!(missing.is_empty(), "{missing:?}");
assert_eq!(e2.session().tracks[0].clips()[0], clip);
assert_eq!(e2.session().videos, e.session().videos);
@@ -173,12 +173,12 @@ mod tests {
std::fs::create_dir_all(dir.join("Video Files")).unwrap();
std::fs::rename(&movie, dir.join("Video Files").join("picture.mp4")).unwrap();
let mut e3 = Engine::default();
assert!(crate::io::open_session(&mut e3, &sess.to_string_lossy()).unwrap().is_empty());
assert!(crate::io::open_session(&mut e3, &sess).unwrap().is_empty());
assert!(e3.session().videos[0].path.contains("Video Files"));
// Movie gone: the session still opens and reports it.
std::fs::remove_file(dir.join("Video Files").join("picture.mp4")).unwrap();
let mut e4 = Engine::default();
let missing = crate::io::open_session(&mut e4, &sess.to_string_lossy()).unwrap();
let missing = crate::io::open_session(&mut e4, &sess).unwrap();
assert!(missing.iter().any(|m| m.contains("picture.mp4")), "{missing:?}");
assert!(e4.messages.iter().any(|m| m.contains("missing media")));
assert_eq!(e4.session().tracks[0].clips().len(), 1);
+1 -347
View File
@@ -917,350 +917,4 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_model::{ChannelFormat, Source, SourceId, TrackKind};
fn session_with_clip() -> (Session, TrackId) {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.sources.push(Source {
id: SourceId(500),
name: "src".into(),
path: "src.wav".into(),
channels: 1,
frames: 10_000,
sample_rate: 48_000,
format: soundcraft_audio_io::FileFormat::Wav,
time_reference: 0,
unsaved: false,
});
let id = s.new_clip_id();
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips.push(Clip::audio(id, "a", SourceId(500), 1000, 1000, 4000));
}
(s, t)
}
fn clips(s: &Session, t: TrackId) -> Vec<(Samples, Samples, Samples)> {
s.track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect()
}
#[test]
fn copy_range_resets_fades_at_cut_edges() {
let (mut s, t) = session_with_clip();
if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()) {
c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
}
let fades = |r: Range| -> Vec<(Samples, Samples)> { copy_range(&s, t, r).iter().map(|c| (c.fade_in.len, c.fade_out.len)).collect() };
assert_eq!(fades(Range::new(2000, 3000)), vec![(0, 0)]);
assert_eq!(fades(Range::new(0, 3000)), vec![(100, 0)]);
assert_eq!(fades(Range::new(2000, 6000)), vec![(0, 100)]);
assert_eq!(fades(Range::new(0, 6000)), vec![(100, 100)]);
}
#[test]
fn separate_splits_and_heal_rejoins() {
let (mut s, t) = session_with_clip();
assert_eq!(separate_at(&mut s, t, 3000), 1);
assert_eq!(clips(&s, t), vec![(1000, 2000, 1000), (3000, 2000, 3000)]);
assert_eq!(separate_at(&mut s, t, 1000), 0);
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
}
#[test]
fn heal_rejoins_cut_separation() {
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1);
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
// A selection that does not span the whole gap leaves it alone.
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
assert_eq!(heal(&mut s, t, Range::new(2500, 3500)), 0);
// Shuffled pieces no longer line up with their source and stay apart.
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), true);
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
// Moved apart: the timeline gap no longer matches the source gap.
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips[1].start = 3500;
}
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
// Overlapping neighbours whose offsets line up are not a separation.
let (mut s, t) = session_with_clip();
assert_eq!(separate_at(&mut s, t, 3000), 1);
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips[1].start = 2500;
}
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
assert_eq!(clips(&s, t).len(), 2);
}
#[test]
fn heal_skips_clips_with_different_playback() {
for tweak in [(|c: &mut Clip| c.muted = true) as fn(&mut Clip), |c: &mut Clip| c.stretch = 1.5] {
let (mut s, t) = session_with_clip();
separate_at(&mut s, t, 3000);
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
tweak(&mut pl.clips[1]);
}
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
assert_eq!(s.track(t).unwrap().clips().len(), 2);
}
}
#[test]
fn heal_keeps_each_side_gain_envelope() {
let cases: [(Vec<(Samples, f32)>, Vec<(Samples, f32)>); 4] = [
(vec![(500, -3.0), (2500, -12.0)], vec![]),
(vec![], vec![(0, -6.0), (1000, 0.0)]),
(vec![(0, 0.0), (1999, -20.0)], vec![(-100, 4.0), (300, -9.0)]),
(vec![(500, -3.0), (2500, -12.0)], vec![(0, -9.75), (500, -12.0)]),
];
for (left_env, right_env) in cases {
let (mut s, t) = session_with_clip();
separate_at(&mut s, t, 3000);
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap();
pl.clips[0].gain_env = left_env;
pl.clips[1].gain_env = right_env;
let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone());
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
let healed = &s.track(t).unwrap().clips()[0];
for rel in 0..4000 {
let want = if rel < 2000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) };
assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel));
}
}
}
#[test]
fn heal_keeps_gain_envelopes_across_a_gap() {
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap();
pl.clips[0].gain_env = vec![(0, 0.0), (999, -6.0)];
pl.clips[1].gain_env = vec![(0, -12.0), (1999, -3.0)];
let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone());
assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1);
let healed = &s.track(t).unwrap().clips()[0];
assert_eq!(healed.length, 4000);
for rel in (0..1000).chain(2000..4000) {
let want = if rel < 1000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) };
assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel));
}
}
#[test]
fn split_then_heal_rejoins_stretched_clip() {
let (mut s, t) = session_with_clip();
if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()) {
c.stretch = 2.0;
}
assert_eq!(separate_at(&mut s, t, 3000), 1);
assert_eq!(clips(&s, t), vec![(1000, 2000, 1000), (3000, 2000, 2000)]);
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
}
#[test]
fn clear_slip_and_shuffle() {
let (mut s, t) = session_with_clip();
let removed = clear_range(&mut s, t, Range::new(2000, 3000), false);
assert_eq!(removed.len(), 1);
assert_eq!(removed[0].start, 0);
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (3000, 2000, 3000)]);
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), true);
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 2000, 3000)]);
}
#[test]
fn copy_paste_overwrites() {
let (mut s, t) = session_with_clip();
let copied = copy_range(&s, t, Range::new(1500, 2500));
assert_eq!(copied.len(), 1);
assert_eq!((copied[0].start, copied[0].length, copied[0].source_offset()), (0, 1000, 1500));
paste(&mut s, t, 4000, &copied, 1000, false).unwrap();
assert_eq!(clips(&s, t), vec![(1000, 3000, 1000), (4000, 1000, 1500)]);
}
#[test]
fn paste_shuffle_ripples() {
let (mut s, t) = session_with_clip();
let copied = copy_range(&s, t, Range::new(1000, 2000));
paste(&mut s, t, 1000, &copied, 1000, true).unwrap();
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 4000, 1000)]);
}
#[test]
fn insert_silence_and_trim() {
let (mut s, t) = session_with_clip();
insert_silence(&mut s, t, Range::new(2000, 2500));
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2500, 3000, 2000)]);
let (mut s, t) = session_with_clip();
trim_to(&mut s, t, Range::new(1500, 2000));
assert_eq!(clips(&s, t), vec![(1500, 500, 1500)]);
}
#[test]
fn fades_and_crossfades() {
let (mut s, t) = session_with_clip();
assert_eq!(create_fades(&mut s, t, Range::new(500, 1500), FadeShape::Linear), 1);
assert_eq!(s.track(t).unwrap().clips()[0].fade_in.len, 500);
let (mut s, t) = session_with_clip();
separate_at(&mut s, t, 3000);
assert_eq!(create_fades(&mut s, t, Range::new(2900, 3100), FadeShape::EqualPower), 1);
let c = s.track(t).unwrap().clips();
assert_eq!(c[0].end(), 3100);
assert_eq!(c[1].start, 2900);
assert_eq!(c[0].fade_out.len, 200);
assert_eq!(c[1].fade_in.len, 200);
delete_fades(&mut s, t, Range::new(2900, 3100));
let c = s.track(t).unwrap().clips();
assert_eq!((c[0].end(), c[1].start), (3000, 3000));
}
fn three_clips() -> (Session, TrackId, [ClipId; 3]) {
let (mut s, t) = session_with_clip();
let ids = [s.new_clip_id(), s.new_clip_id(), s.new_clip_id()];
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips = vec![
Clip::audio(ids[0], "a", SourceId(500), 0, 0, 1000),
Clip::audio(ids[1], "b", SourceId(500), 1000, 1000, 2000),
Clip::audio(ids[2], "c", SourceId(500), 3000, 5000, 1000),
];
}
(s, t, ids)
}
#[test]
fn shuffle_moves_reorder_clips_and_keep_the_silence() {
let (mut s, t, [_, b, _]) = three_clips();
assert_eq!(shuffle_clips(&mut s, &[b], -900, &[]), Ok(1));
assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]);
let (mut s, t, [a, ..]) = three_clips();
assert_eq!(shuffle_slot(&s, t, 900, &[a]), 0);
assert_eq!(shuffle_slot(&s, t, 1100, &[a]), 2000);
shuffle_clips(&mut s, &[a], 2500, &[]).unwrap();
assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]);
}
#[test]
fn shuffling_onto_an_empty_track_starts_it_at_zero() {
let (mut s, t, [_, _, c]) = three_clips();
let other = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
shuffle_clips(&mut s, &[c], 40_000, &[(t, other)]).unwrap();
assert_eq!(clips(&s, other), vec![(0, 1000, 3000)]);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000)]);
}
#[test]
fn shuffle_trims_move_later_clips_and_slip_trims_stop_at_neighbours() {
let (mut s, t, [a, b, c]) = three_clips();
trim_clip(&mut s, b, None, Some(2500), true);
trim_clip(&mut s, b, Some(1500), None, true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 1000, 1500), (4000, 1000, 3000)]);
let (mut s, t, _) = three_clips();
trim_clip(&mut s, a, None, Some(2500), false);
trim_clip(&mut s, c, Some(2000), None, false);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000), (3000, 3000, 1000)]);
trim_clip(&mut s, c, None, Some(99_999), false);
assert_eq!(clips(&s, t)[2], (3000, 9000, 1000));
}
#[test]
fn shuffle_leaves_locked_clips_and_what_follows_them_in_place() {
let (mut s, t, [a, b, c]) = three_clips();
s.find_clip_mut(c).unwrap().time_locked = true;
clear_range(&mut s, t, Range::new(1000, 3000), true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (5000, 1000, 3000)]);
let (mut s, t, [a2, ..]) = three_clips();
s.find_clip_mut(c).unwrap().edit_locked = true;
assert_eq!(shuffle_room(&s, t, 0), 2000);
assert_eq!(make_room(&mut s, t, 0, 2500), Err(NO_ROOM));
assert_eq!(make_room(&mut s, t, 0, 2000), Ok(()));
assert_eq!(clips(&s, t), vec![(2000, 1000, 0), (3000, 2000, 1000), (5000, 1000, 3000)]);
assert_eq!(shuffle_clips(&mut s, &[a2], 2500, &[]), Ok(1));
assert_eq!(clips(&s, t)[2], (5000, 1000, 3000));
let (mut s, t, _) = three_clips();
s.find_clip_mut(c).unwrap().time_locked = true;
trim_clip(&mut s, b, None, Some(9000), true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 4000, 1000), (5000, 1000, 3000)]);
assert_eq!(shuffle_slot(&s, t, 4000, &[a]), 4000);
}
#[test]
fn multi_track_moves_keep_the_tracks_apart() {
let (mut s, t1, [a, ..]) = three_clips();
let t2 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let t3 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let m = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let d = s.new_clip_id();
s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(500), 0, 0, 500));
let dest = destinations(&s, &[a, d], t1, t2);
assert_eq!(dest, Some(vec![(t1, t2), (t2, t3)]));
assert_eq!(destinations(&s, &[a, d], t1, m), None);
assert_eq!(destinations(&s, &[a], t1, m), None);
assert_eq!(move_clips(&mut s, &[a, d], 0, &dest.unwrap()), 2);
assert_eq!(clips(&s, t2), vec![(0, 1000, 0)]);
assert_eq!(clips(&s, t3), vec![(0, 500, 0)]);
}
#[test]
fn grid_moves_snap_the_clip_start_or_keep_its_offset() {
let (s, t) = session_with_clip();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
let c = s.track(t).unwrap().clips()[0].clone();
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000);
assert_eq!(grid_delta(&s, &c, step / 3, true), 0);
}
#[test]
fn moves_overwrite_and_respect_locks() {
let (mut s, t) = session_with_clip();
let id = s.track(t).unwrap().clips()[0].id;
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 1);
assert_eq!(clips(&s, t), vec![(1500, 4000, 1000)]);
s.find_clip_mut(id).unwrap().time_locked = true;
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0);
}
#[test]
fn delete_fades_keeps_playlist_sorted_when_a_clip_overlaps_several() {
let (mut s, t) = session_with_clip();
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).expect("track has a playlist");
pl.clips.clear();
pl.clips.push(Clip::audio(ClipId(901), "a", SourceId(500), 0, 0, 1000));
pl.clips.push(Clip::audio(ClipId(902), "b", SourceId(500), 100, 100, 100));
pl.clips.push(Clip::audio(ClipId(903), "c", SourceId(500), 150, 150, 750));
delete_fades(&mut s, t, Range::new(0, 10_000));
let got = clips(&s, t);
assert!(got.windows(2).all(|w| w[0].0 <= w[1].0), "playlist not sorted: {got:?}");
assert!(got.iter().all(|c| c.1 > 0), "empty clip: {got:?}");
}
#[test]
fn delete_fades_leaves_unrelated_overlaps_alone() {
let (mut s, t) = session_with_clip();
let ids: Vec<ClipId> = (0..4).map(|_| s.new_clip_id()).collect();
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips.clear();
for (id, start, len) in [(ids[0], 0, 1100), (ids[1], 1000, 1000), (ids[2], 5000, 1100), (ids[3], 6000, 1000)] {
let mut c = Clip::audio(id, "c", SourceId(500), start, start, len);
c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
pl.clips.push(c);
}
}
assert_eq!(delete_fades(&mut s, t, Range::new(900, 1200)), 2);
assert_eq!(clips(&s, t), vec![(0, 1050, 0), (1050, 950, 1050), (5000, 1100, 5000), (6000, 1000, 6000)]);
let c = s.track(t).unwrap().clips();
assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100));
}
}
mod tests;
+345
View File
@@ -0,0 +1,345 @@
use super::*;
use soundcraft_model::{ChannelFormat, Source, SourceId, TrackKind};
fn session_with_clip() -> (Session, TrackId) {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.sources.push(Source {
id: SourceId(500),
name: "src".into(),
path: "src.wav".into(),
channels: 1,
frames: 10_000,
sample_rate: 48_000,
format: soundcraft_audio_io::FileFormat::Wav,
time_reference: 0,
unsaved: false,
});
let id = s.new_clip_id();
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips.push(Clip::audio(id, "a", SourceId(500), 1000, 1000, 4000));
}
(s, t)
}
fn clips(s: &Session, t: TrackId) -> Vec<(Samples, Samples, Samples)> {
s.track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect()
}
#[test]
fn copy_range_resets_fades_at_cut_edges() {
let (mut s, t) = session_with_clip();
if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()) {
c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
}
let fades = |r: Range| -> Vec<(Samples, Samples)> { copy_range(&s, t, r).iter().map(|c| (c.fade_in.len, c.fade_out.len)).collect() };
assert_eq!(fades(Range::new(2000, 3000)), vec![(0, 0)]);
assert_eq!(fades(Range::new(0, 3000)), vec![(100, 0)]);
assert_eq!(fades(Range::new(2000, 6000)), vec![(0, 100)]);
assert_eq!(fades(Range::new(0, 6000)), vec![(100, 100)]);
}
#[test]
fn separate_splits_and_heal_rejoins() {
let (mut s, t) = session_with_clip();
assert_eq!(separate_at(&mut s, t, 3000), 1);
assert_eq!(clips(&s, t), vec![(1000, 2000, 1000), (3000, 2000, 3000)]);
assert_eq!(separate_at(&mut s, t, 1000), 0);
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
}
#[test]
fn heal_rejoins_cut_separation() {
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1);
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
// A selection that does not span the whole gap leaves it alone.
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
assert_eq!(heal(&mut s, t, Range::new(2500, 3500)), 0);
// Shuffled pieces no longer line up with their source and stay apart.
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), true);
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
// Moved apart: the timeline gap no longer matches the source gap.
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips[1].start = 3500;
}
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
// Overlapping neighbours whose offsets line up are not a separation.
let (mut s, t) = session_with_clip();
assert_eq!(separate_at(&mut s, t, 3000), 1);
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips[1].start = 2500;
}
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
assert_eq!(clips(&s, t).len(), 2);
}
#[test]
fn heal_skips_clips_with_different_playback() {
for tweak in [(|c: &mut Clip| c.muted = true) as fn(&mut Clip), |c: &mut Clip| c.stretch = 1.5] {
let (mut s, t) = session_with_clip();
separate_at(&mut s, t, 3000);
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
tweak(&mut pl.clips[1]);
}
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
assert_eq!(s.track(t).unwrap().clips().len(), 2);
}
}
#[test]
fn heal_keeps_each_side_gain_envelope() {
let cases: [(Vec<(Samples, f32)>, Vec<(Samples, f32)>); 4] = [
(vec![(500, -3.0), (2500, -12.0)], vec![]),
(vec![], vec![(0, -6.0), (1000, 0.0)]),
(vec![(0, 0.0), (1999, -20.0)], vec![(-100, 4.0), (300, -9.0)]),
(vec![(500, -3.0), (2500, -12.0)], vec![(0, -9.75), (500, -12.0)]),
];
for (left_env, right_env) in cases {
let (mut s, t) = session_with_clip();
separate_at(&mut s, t, 3000);
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap();
pl.clips[0].gain_env = left_env;
pl.clips[1].gain_env = right_env;
let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone());
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
let healed = &s.track(t).unwrap().clips()[0];
for rel in 0..4000 {
let want = if rel < 2000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) };
assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel));
}
}
}
#[test]
fn heal_keeps_gain_envelopes_across_a_gap() {
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap();
pl.clips[0].gain_env = vec![(0, 0.0), (999, -6.0)];
pl.clips[1].gain_env = vec![(0, -12.0), (1999, -3.0)];
let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone());
assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1);
let healed = &s.track(t).unwrap().clips()[0];
assert_eq!(healed.length, 4000);
for rel in (0..1000).chain(2000..4000) {
let want = if rel < 1000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) };
assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel));
}
}
#[test]
fn split_then_heal_rejoins_stretched_clip() {
let (mut s, t) = session_with_clip();
if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()) {
c.stretch = 2.0;
}
assert_eq!(separate_at(&mut s, t, 3000), 1);
assert_eq!(clips(&s, t), vec![(1000, 2000, 1000), (3000, 2000, 2000)]);
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
}
#[test]
fn clear_slip_and_shuffle() {
let (mut s, t) = session_with_clip();
let removed = clear_range(&mut s, t, Range::new(2000, 3000), false);
assert_eq!(removed.len(), 1);
assert_eq!(removed[0].start, 0);
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (3000, 2000, 3000)]);
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), true);
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 2000, 3000)]);
}
#[test]
fn copy_paste_overwrites() {
let (mut s, t) = session_with_clip();
let copied = copy_range(&s, t, Range::new(1500, 2500));
assert_eq!(copied.len(), 1);
assert_eq!((copied[0].start, copied[0].length, copied[0].source_offset()), (0, 1000, 1500));
paste(&mut s, t, 4000, &copied, 1000, false).unwrap();
assert_eq!(clips(&s, t), vec![(1000, 3000, 1000), (4000, 1000, 1500)]);
}
#[test]
fn paste_shuffle_ripples() {
let (mut s, t) = session_with_clip();
let copied = copy_range(&s, t, Range::new(1000, 2000));
paste(&mut s, t, 1000, &copied, 1000, true).unwrap();
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 4000, 1000)]);
}
#[test]
fn insert_silence_and_trim() {
let (mut s, t) = session_with_clip();
insert_silence(&mut s, t, Range::new(2000, 2500));
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2500, 3000, 2000)]);
let (mut s, t) = session_with_clip();
trim_to(&mut s, t, Range::new(1500, 2000));
assert_eq!(clips(&s, t), vec![(1500, 500, 1500)]);
}
#[test]
fn fades_and_crossfades() {
let (mut s, t) = session_with_clip();
assert_eq!(create_fades(&mut s, t, Range::new(500, 1500), FadeShape::Linear), 1);
assert_eq!(s.track(t).unwrap().clips()[0].fade_in.len, 500);
let (mut s, t) = session_with_clip();
separate_at(&mut s, t, 3000);
assert_eq!(create_fades(&mut s, t, Range::new(2900, 3100), FadeShape::EqualPower), 1);
let c = s.track(t).unwrap().clips();
assert_eq!(c[0].end(), 3100);
assert_eq!(c[1].start, 2900);
assert_eq!(c[0].fade_out.len, 200);
assert_eq!(c[1].fade_in.len, 200);
delete_fades(&mut s, t, Range::new(2900, 3100));
let c = s.track(t).unwrap().clips();
assert_eq!((c[0].end(), c[1].start), (3000, 3000));
}
fn three_clips() -> (Session, TrackId, [ClipId; 3]) {
let (mut s, t) = session_with_clip();
let ids = [s.new_clip_id(), s.new_clip_id(), s.new_clip_id()];
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips = vec![
Clip::audio(ids[0], "a", SourceId(500), 0, 0, 1000),
Clip::audio(ids[1], "b", SourceId(500), 1000, 1000, 2000),
Clip::audio(ids[2], "c", SourceId(500), 3000, 5000, 1000),
];
}
(s, t, ids)
}
#[test]
fn shuffle_moves_reorder_clips_and_keep_the_silence() {
let (mut s, t, [_, b, _]) = three_clips();
assert_eq!(shuffle_clips(&mut s, &[b], -900, &[]), Ok(1));
assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]);
let (mut s, t, [a, ..]) = three_clips();
assert_eq!(shuffle_slot(&s, t, 900, &[a]), 0);
assert_eq!(shuffle_slot(&s, t, 1100, &[a]), 2000);
shuffle_clips(&mut s, &[a], 2500, &[]).unwrap();
assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]);
}
#[test]
fn shuffling_onto_an_empty_track_starts_it_at_zero() {
let (mut s, t, [_, _, c]) = three_clips();
let other = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
shuffle_clips(&mut s, &[c], 40_000, &[(t, other)]).unwrap();
assert_eq!(clips(&s, other), vec![(0, 1000, 3000)]);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000)]);
}
#[test]
fn shuffle_trims_move_later_clips_and_slip_trims_stop_at_neighbours() {
let (mut s, t, [a, b, c]) = three_clips();
trim_clip(&mut s, b, None, Some(2500), true);
trim_clip(&mut s, b, Some(1500), None, true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 1000, 1500), (4000, 1000, 3000)]);
let (mut s, t, _) = three_clips();
trim_clip(&mut s, a, None, Some(2500), false);
trim_clip(&mut s, c, Some(2000), None, false);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000), (3000, 3000, 1000)]);
trim_clip(&mut s, c, None, Some(99_999), false);
assert_eq!(clips(&s, t)[2], (3000, 9000, 1000));
}
#[test]
fn shuffle_leaves_locked_clips_and_what_follows_them_in_place() {
let (mut s, t, [a, b, c]) = three_clips();
s.find_clip_mut(c).unwrap().time_locked = true;
clear_range(&mut s, t, Range::new(1000, 3000), true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (5000, 1000, 3000)]);
let (mut s, t, [a2, ..]) = three_clips();
s.find_clip_mut(c).unwrap().edit_locked = true;
assert_eq!(shuffle_room(&s, t, 0), 2000);
assert_eq!(make_room(&mut s, t, 0, 2500), Err(NO_ROOM));
assert_eq!(make_room(&mut s, t, 0, 2000), Ok(()));
assert_eq!(clips(&s, t), vec![(2000, 1000, 0), (3000, 2000, 1000), (5000, 1000, 3000)]);
assert_eq!(shuffle_clips(&mut s, &[a2], 2500, &[]), Ok(1));
assert_eq!(clips(&s, t)[2], (5000, 1000, 3000));
let (mut s, t, _) = three_clips();
s.find_clip_mut(c).unwrap().time_locked = true;
trim_clip(&mut s, b, None, Some(9000), true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 4000, 1000), (5000, 1000, 3000)]);
assert_eq!(shuffle_slot(&s, t, 4000, &[a]), 4000);
}
#[test]
fn multi_track_moves_keep_the_tracks_apart() {
let (mut s, t1, [a, ..]) = three_clips();
let t2 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let t3 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let m = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let d = s.new_clip_id();
s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(500), 0, 0, 500));
let dest = destinations(&s, &[a, d], t1, t2);
assert_eq!(dest, Some(vec![(t1, t2), (t2, t3)]));
assert_eq!(destinations(&s, &[a, d], t1, m), None);
assert_eq!(destinations(&s, &[a], t1, m), None);
assert_eq!(move_clips(&mut s, &[a, d], 0, &dest.unwrap()), 2);
assert_eq!(clips(&s, t2), vec![(0, 1000, 0)]);
assert_eq!(clips(&s, t3), vec![(0, 500, 0)]);
}
#[test]
fn grid_moves_snap_the_clip_start_or_keep_its_offset() {
let (s, t) = session_with_clip();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
let c = s.track(t).unwrap().clips()[0].clone();
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000);
assert_eq!(grid_delta(&s, &c, step / 3, true), 0);
}
#[test]
fn moves_overwrite_and_respect_locks() {
let (mut s, t) = session_with_clip();
let id = s.track(t).unwrap().clips()[0].id;
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 1);
assert_eq!(clips(&s, t), vec![(1500, 4000, 1000)]);
s.find_clip_mut(id).unwrap().time_locked = true;
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0);
}
#[test]
fn delete_fades_keeps_playlist_sorted_when_a_clip_overlaps_several() {
let (mut s, t) = session_with_clip();
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).expect("track has a playlist");
pl.clips.clear();
pl.clips.push(Clip::audio(ClipId(901), "a", SourceId(500), 0, 0, 1000));
pl.clips.push(Clip::audio(ClipId(902), "b", SourceId(500), 100, 100, 100));
pl.clips.push(Clip::audio(ClipId(903), "c", SourceId(500), 150, 150, 750));
delete_fades(&mut s, t, Range::new(0, 10_000));
let got = clips(&s, t);
assert!(got.windows(2).all(|w| w[0].0 <= w[1].0), "playlist not sorted: {got:?}");
assert!(got.iter().all(|c| c.1 > 0), "empty clip: {got:?}");
}
#[test]
fn delete_fades_leaves_unrelated_overlaps_alone() {
let (mut s, t) = session_with_clip();
let ids: Vec<ClipId> = (0..4).map(|_| s.new_clip_id()).collect();
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips.clear();
for (id, start, len) in [(ids[0], 0, 1100), (ids[1], 1000, 1000), (ids[2], 5000, 1100), (ids[3], 6000, 1000)] {
let mut c = Clip::audio(id, "c", SourceId(500), start, start, len);
c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
pl.clips.push(c);
}
}
assert_eq!(delete_fades(&mut s, t, Range::new(900, 1200)), 2);
assert_eq!(clips(&s, t), vec![(0, 1050, 0), (1050, 950, 1050), (5000, 1100, 5000), (6000, 1000, 6000)]);
let c = s.track(t).unwrap().clips();
assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100));
}
+35 -14
View File
@@ -48,16 +48,28 @@ struct Root {
shown: PathBuf,
}
/// A root [`AutomationRoots::new`] can't open: missing, unreadable, or not a folder.
#[derive(Debug, thiserror::Error)]
#[error("cannot open the automation {} root `{}`: {source}", kind(.access), .path.display())]
pub struct RootError {
access: Access,
path: PathBuf,
source: std::io::Error,
}
fn kind(access: &Access) -> &'static str {
match access {
Access::Read => "read",
Access::Write => "write",
}
}
impl Root {
/// The folder at `path`, which must exist.
fn open(path: &Path, access: Access) -> Result<Self, String> {
let kind = match access {
Access::Read => "read",
Access::Write => "write",
};
fn open(path: &Path, access: Access) -> Result<Self, RootError> {
let resolved = std::fs::canonicalize(path)
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
.map_err(|source| RootError { access, path: path.to_owned(), source })?;
Ok(Self { shown: plain(resolved.clone()), resolved })
}
}
@@ -65,11 +77,11 @@ impl Root {
impl AutomationRoots {
/// The roots `read` and `write`, which must be existing folders (one given twice counts once);
/// `None` when neither kind has any (nothing is confined then).
pub fn new(read: &[&Path], write: &[&Path]) -> Result<Option<Arc<Self>>, String> {
pub fn new(read: &[&Path], write: &[&Path]) -> Result<Option<Arc<Self>>, RootError> {
if read.is_empty() && write.is_empty() {
return Ok(None);
}
let open = |paths: &[&Path], access: Access| -> Result<Vec<Root>, String> {
let open = |paths: &[&Path], access: Access| -> Result<Vec<Root>, RootError> {
let mut roots: Vec<Root> = Vec::with_capacity(paths.len());
for path in paths {
let root = Root::open(path, access)?;
@@ -354,15 +366,15 @@ mod tests {
#[test]
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
let (base, _) = folders("missing");
let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err();
let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err().to_string();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let e = AutomationRoots::new(&[], &[&base.join("in/a.scraft")]).unwrap_err();
let e = AutomationRoots::new(&[], &[&base.join("in/a.scraft")]).unwrap_err().to_string();
assert!(
e.starts_with("cannot open the automation write root")
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
"{e}"
);
assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().to_string().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
// One root alone grants only its own access.
let read_only = AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap();
@@ -440,7 +452,7 @@ mod tests {
let twice = AutomationRoots::new(&[&base.join("in"), &base.join("in/sub/..")], &[]).unwrap().unwrap();
assert!(!twice.describe().contains(", ") && twice.describe().ends_with("; write none"), "{}", twice.describe());
// A missing folder among several is an error.
let e = AutomationRoots::new(&[&base.join("in"), &base.join("nope")], &[]).unwrap_err();
let e = AutomationRoots::new(&[&base.join("in"), &base.join("nope")], &[]).unwrap_err().to_string();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
@@ -540,8 +552,17 @@ mod tests {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
// Test modules (which build their own fixtures) come last in each file.
let code = text.split("#[cfg(test)]").next().unwrap_or("");
// Inline test modules (which build their own fixtures) come last in each file. Cut only
// there: `#[cfg(test)]` also marks `use` lines and `mod tests;` declarations above code.
let lines: Vec<&str> = text.lines().collect();
let test_module =
(0..lines.len()).find(|&i| {
lines[i].trim() == "#[cfg(test)]"
&& lines[i + 1..].iter().map(|l| l.trim()).find(|l| !l.starts_with("#[")).is_some_and(|l| {
(l.starts_with("mod ") || l.starts_with("pub mod ") || l.starts_with("pub(crate) mod ")) && l.ends_with('{')
})
});
let code = lines[..test_module.unwrap_or(lines.len())].join("\n");
for (n, line) in code.lines().enumerate() {
let line = line.trim_start();
let touches =
+18 -1918
View File
File diff suppressed because it is too large Load Diff
+258
View File
@@ -0,0 +1,258 @@
use super::render::sanitize_name;
use super::session::decode_source;
use super::{Arc, EncodeOptions, Engine, EngineError, FileFormat, OsStr, Path, Result, Session, SourceAudio, read_bundle, write_file};
// ─── Hosts without a filesystem (the browser) ─────────────────────────────────────────────────
/// Extension of a session bundle: one zip file holding the `.scraft` session and its
/// `Audio Files/`, the same layout as a saved session folder. The browser has no folders, so it
/// saves and opens sessions as bundles.
pub const BUNDLE_EXTENSION: &str = "scraftz";
/// Most entries and bytes a bundle may unpack to (bundles are hostile input). The writer keeps to
/// them too, so a bundle it saves always opens again (#433).
#[derive(Clone, Copy, Debug)]
pub(super) struct BundleLimits {
pub(super) entries: usize,
pub(super) bytes: u64,
}
const BUNDLE_LIMITS: BundleLimits = BundleLimits { entries: 10_000, bytes: 1 << 30 };
/// The session as a bundle: `<name>.scraft` plus every source's audio as 32-bit float WAV under
/// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the
/// session with its source paths pointing into the bundle.
pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
session_bundle_within(session, BUNDLE_LIMITS)
}
/// [`session_bundle`], refused when it would hold more than `limits` allows.
fn session_bundle_within(session: &Session, limits: BundleLimits) -> Result<(Vec<u8>, Session)> {
use std::io::Write as _;
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}"));
let mut s = session.clone();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true);
let mut taken = std::collections::HashSet::new();
let (mut entries, mut total) = (0usize, 0u64);
// Counted as the reader counts them: files, and the bytes they unpack to.
let mut fits = |len: usize| {
entries += 1;
total = total.saturating_add(len as u64);
if entries > limits.entries || total > limits.bytes {
return Err(EngineError::Io(format!(
"bundle: too large to save (a bundle holds at most {} files and {} MiB, or it can't be opened again)",
limits.entries,
limits.bytes >> 20
)));
}
Ok(())
};
for src in &mut s.sources {
let Some(audio) = s.pool.get(src.id) else { continue };
let base = sanitize_name(&src.name);
let mut target = format!("Audio Files/{base}.wav");
let mut k = 1;
while taken.contains(&target) && k <= 9_999 {
target = format!("Audio Files/{base}_{k:02}.wav");
k += 1;
}
let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?;
fits(bytes.len())?;
w.start_file(target.as_str(), opts).map_err(zerr)?;
w.write_all(&bytes).map_err(ioerr)?;
taken.insert(target.clone());
src.path = target;
src.unsaved = false;
src.format = FileFormat::Wav;
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
fits(text.len())?;
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
w.write_all(text.as_bytes()).map_err(ioerr)?;
let bytes = w.finish().map_err(zerr)?.into_inner();
Ok((bytes, s))
}
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]).
pub(super) fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
let stem = Path::new(path).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).map(str::to_string);
let mut s = e.session().clone();
if let Some(stem) = stem {
s.name = stem;
}
let (bytes, fresh) = session_bundle(&s)?;
let file = format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&fresh.name));
write_file(&file, &bytes)?;
let written = fresh.sources.iter().filter(|x| fresh.pool.get(x.id).is_some()).count();
*e.session_mut() = fresh;
e.path = Some(file);
e.mark_clean();
Ok(written)
}
/// Open a session from bytes (the browser has no paths): a bundle ([`session_bundle`]) or a bare
/// `.scraft`, whose audio is then reported missing. Returns the missing media, like
/// [`open_session`].
pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Vec<String>> {
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
read_bundle(bytes, BUNDLE_LIMITS)?
} else {
(
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
std::collections::HashMap::new(),
)
};
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let mut missing = Vec::new();
// Several sources may share one file (native sessions do): decode each path once and share the result.
let mut decoded: std::collections::HashMap<String, Option<Arc<SourceAudio>>> = std::collections::HashMap::new();
for src in &s.sources {
if let Some(audio) = decoded.get(&src.path) {
if let Some(audio) = audio {
s.pool.insert(src.id, Arc::clone(audio));
}
continue;
}
let audio = match files.remove(&src.path) {
Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(OsStr::to_str), s.sample_rate.hz()) {
Ok(audio) => Some(audio),
Err(err) => {
missing.push(format!("{}: {err}", src.path));
None
}
},
None => {
missing.push(src.path.clone());
None
}
};
if let Some(audio) = &audio {
s.pool.insert(src.id, Arc::clone(audio));
}
decoded.insert(src.path.clone(), audio);
}
// Movies are referenced by path, never copied into a session: none can be found here.
missing.extend(s.videos.iter().map(|v| v.path.clone()));
let stem = Path::new(name).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string();
e.replace_session(s);
e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
for m in &missing {
e.message(format!("missing media: {m}"));
}
Ok(missing)
}
#[cfg(test)]
mod bundle_tests {
use super::*;
#[test]
fn bundle_round_trips_session_and_audio() {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap();
let (bytes, fresh) = session_bundle(e.session()).unwrap();
assert!(bytes.starts_with(b"PK\x03\x04"));
assert!(fresh.sources.iter().all(|s| s.path.starts_with("Audio Files/") && !s.unsaved));
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Midnight Groove.scraftz", &bytes).unwrap();
assert!(missing.is_empty(), "{missing:?}");
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
assert_eq!(r.session().tracks.len(), e.session().tracks.len());
for src in &e.session().sources {
let (a, b) = (e.session().pool.get(src.id).unwrap(), r.session().pool.get(src.id).unwrap());
assert_eq!(a.buffer.channels, b.buffer.channels, "{}: float WAV keeps every sample", src.name);
}
assert!(!r.is_dirty());
assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz"));
}
#[test]
fn sources_sharing_one_file_all_load_from_a_bundle() {
use std::io::Write as _;
let e = crate::demo::demo_engine();
let (bytes, _) = session_bundle(e.session()).unwrap();
let (text, files) = read_bundle(&bytes, BUNDLE_LIMITS).unwrap();
let mut s = Session::from_json(&text).unwrap();
s.sources[1].path = s.sources[0].path.clone();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for path in s.sources.iter().map(|x| &x.path).collect::<std::collections::BTreeSet<_>>() {
w.start_file(path.as_str(), opts).unwrap();
w.write_all(&files[path]).unwrap();
}
w.start_file("Song.scraft", opts).unwrap();
w.write_all(s.to_json().unwrap().as_bytes()).unwrap();
let bundle = w.finish().unwrap().into_inner();
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Song.scraftz", &bundle).unwrap();
assert!(missing.is_empty(), "{missing:?}");
for src in &r.session().sources {
assert!(r.session().pool.get(src.id).is_some(), "{} has no audio", src.name);
}
}
#[test]
fn a_bundle_is_saved_only_if_it_opens_again() {
// #433: the writer keeps to the reader's limits.
let e = crate::demo::demo_engine();
let (bytes, _) = session_bundle(e.session()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let entries = z.len();
let size: u64 = (0..entries).map(|i| z.by_index(i).unwrap().size()).sum();
let exact = BundleLimits { entries, bytes: size };
let (at_limit, _) = session_bundle_within(e.session(), exact).unwrap();
assert!(read_bundle(&at_limit, exact).is_ok(), "what the writer takes, the reader opens");
assert!(session_bundle_within(e.session(), BundleLimits { entries: entries - 1, ..exact }).is_err());
assert!(session_bundle_within(e.session(), BundleLimits { bytes: size - 1, ..exact }).is_err());
}
#[test]
fn same_named_sources_get_distinct_files() {
let mut e = crate::demo::demo_engine();
let name = e.session().sources[0].name.clone();
let s = e.session_mut();
let n = s.sources.len();
s.sources[1].name = name;
let (bytes, fresh) = session_bundle(e.session()).unwrap();
let paths: std::collections::HashSet<_> = fresh.sources.iter().map(|s| s.path.clone()).collect();
assert_eq!(paths.len(), n);
let mut r = Engine::default();
assert!(open_session_bytes(&mut r, "x.scraftz", &bytes).unwrap().is_empty());
}
#[test]
fn bare_session_opens_with_its_audio_reported_missing() {
let e = crate::demo::demo_engine();
let (_, fresh) = session_bundle(e.session()).unwrap();
let text = fresh.to_json().unwrap();
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Song.scraft", text.as_bytes()).unwrap();
assert_eq!(missing.len(), fresh.sources.len());
assert_eq!(r.session().tracks.len(), fresh.tracks.len());
}
#[test]
fn hostile_bundles_are_errors_not_panics() {
let mut r = Engine::default();
for bad in [&b"PK\x03\x04 not really a zip"[..], b"", b"{ not json", b"\xff\xfe\x00"] {
assert!(open_session_bytes(&mut r, "bad.scraftz", bad).is_err());
}
// A valid zip without a session inside.
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("Audio Files/a.wav", zip::write::SimpleFileOptions::default()).unwrap();
let only_audio = w.finish().unwrap().into_inner();
assert!(open_session_bytes(&mut r, "a.scraftz", &only_audio).is_err());
assert_eq!(r.session().tracks.len(), 0, "a failed open leaves the document alone");
}
#[test]
fn write_file_writes_to_disk_natively() {
let p = std::env::temp_dir().join(format!("soundcraft-write-file-{}.txt", std::process::id()));
write_file(&p.to_string_lossy(), b"hello").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"hello");
let _ = std::fs::remove_file(&p);
}
}
+222
View File
@@ -0,0 +1,222 @@
use super::render::sanitize_name;
use super::{
Arc, AudioBuffer, ChannelFormat, Clip, Engine, EngineError, FileFormat, OsStr, Path, PathBuf, Result, Samples, Session, Source, SourceAudio,
SourceId, TrackId, TrackKind, Value, file_found, file_key, json, read_file, stored_path,
};
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples: a file with a
/// tiny sample rate grows by orders of magnitude.
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option<AudioBuffer> {
let from = buf.sample_rate.max(1);
if buf.frames() as f64 * buf.num_channels() as f64 * f64::from(rate) / f64::from(from) > limit as f64 {
return None;
}
if from != rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate);
buf.sample_rate = rate;
}
Some(buf)
}
/// A relative `Audio Files/` path no other source in `s` already uses.
fn unique_audio_rel(s: &Session, stem: &str) -> String {
let stem = sanitize_name(stem);
let (plain, prefix) = (format!("Audio Files/{stem}.wav"), format!("Audio Files/{stem}-"));
let used: std::collections::HashSet<u64> = s
.sources
.iter()
.filter_map(|src| {
if src.path == plain {
return Some(1);
}
let n = src.path.strip_prefix(&prefix)?.strip_suffix(".wav")?;
n.parse().ok().filter(|i: &u64| *i > 1 && i.to_string() == n)
})
.collect();
match (1..).find(|i| !used.contains(i)) {
Some(n) if n > 1 => format!("{prefix}{n}.wav"),
_ => plain,
}
}
/// Register decoded audio as a new source. Resamples to the session rate if needed.
pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Option<&str>, format: FileFormat) -> SourceId {
let sr = s.sample_rate.hz();
if buf.sample_rate != sr && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr);
buf.sample_rate = sr;
}
add_source_audio(s, name, Arc::new(SourceAudio::new(buf)), path, format)
}
/// Register audio already at the session rate, with its peaks built, as a new source.
fn add_source_audio(s: &mut Session, name: &str, audio: Arc<SourceAudio>, path: Option<&str>, format: FileFormat) -> SourceId {
let id = SourceId(s.alloc());
let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or(name).to_string();
let stored = match path {
Some(p) => p.to_string(),
None => unique_audio_rel(s, &stem),
};
s.sources.push(Source {
id,
name: stem.clone(),
path: stored,
channels: u16::try_from(audio.buffer.num_channels()).unwrap_or(u16::MAX),
frames: audio.frames() as u64,
sample_rate: s.sample_rate.hz(),
format,
time_reference: 0,
unsaved: true,
});
s.pool.insert(id, audio);
id
}
/// A BWF time stamp (samples at the file's rate) at the session's rate, as the audio is resampled.
fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 {
if file_rate == 0 || file_rate == session_rate {
return stamp;
}
let (from, to) = (u128::from(file_rate), u128::from(session_rate));
u64::try_from((u128::from(stamp) * to + from / 2) / from).unwrap_or(u64::MAX)
}
/// An audio file decoded, resampled to a session rate and given its waveform peaks: everything an
/// import costs except placing the clip. Making one needs no engine, so the app makes it on a
/// worker thread ([`decode_import`]) and hands it over with [`Engine::offer_import`] before it
/// runs `file.import_audio`, which then only places the clip (issue #597).
#[derive(Debug, Clone)]
pub struct DecodedImport {
pub(crate) key: PathBuf,
pub(crate) rate: u32,
info: soundcraft_audio_io::AudioInfo,
audio: Arc<SourceAudio>,
}
impl DecodedImport {
/// Frames at the session rate it was decoded for.
pub fn frames(&self) -> usize {
self.audio.frames()
}
}
/// Reads and decodes the audio file at `path` for a session at `rate`. Thread-safe and slow (the
/// whole file is read, decoded, resampled and given peaks): run it off the UI thread.
pub fn decode_import(path: &str, rate: u32) -> Result<DecodedImport> {
let bytes = read_file(path)?;
let name = Path::new(path).file_name().and_then(OsStr::to_str).unwrap_or("Audio");
let (info, audio) = decode_import_bytes(name, &bytes, rate)?;
Ok(DecodedImport { key: file_key(Path::new(path)), rate, info, audio })
}
fn decode_import_bytes(name: &str, bytes: &[u8], rate: u32) -> Result<(soundcraft_audio_io::AudioInfo, Arc<SourceAudio>)> {
let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
if buf.frames() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
Ok((info, Arc::new(SourceAudio::new(buf))))
}
/// Decode and place an audio file. With no target track, a new track of matching width is made.
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
let (info, audio) = decode_import_bytes(name, bytes, e.session().sample_rate.hz())?;
place_import(e, name, &info, audio, path, target, at)
}
/// Place audio from [`decode_import`] (made for this session's rate) as `import_audio_bytes` does.
pub(crate) fn place_decoded(e: &mut Engine, name: &str, d: DecodedImport, path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
place_import(e, name, &d.info, d.audio, path, target, at)
}
fn place_import(
e: &mut Engine,
name: &str,
info: &soundcraft_audio_io::AudioInfo,
audio: Arc<SourceAudio>,
path: Option<&str>,
target: Option<TrackId>,
at: Samples,
) -> Result<Value> {
let channels = audio.buffer.num_channels();
let file_rate = info.sample_rate;
let s = e.session_mut();
let session_rate = s.sample_rate.hz();
let src = add_source_audio(s, name, audio, path, info.format);
if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) {
sx.time_reference = stamp_at_rate(bwf.time_reference, file_rate, session_rate);
}
let frames = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0));
let track = match target {
Some(t) => t,
None => {
let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or("Audio").to_string();
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem))
}
};
let cid = s.new_clip_id();
let stem = s.source(src).map(|x| x.name.clone()).unwrap_or_default();
crate::edit::place_clip(s, track, Clip::audio(cid, stem, src, 0, at, frames));
Ok(json!({"track": track, "clip": cid, "source": src, "frames": frames, "channels": channels}))
}
/// Loads an audio file's bytes into an instrument track's Sampler as one undo step ("Load
/// Sampler Sample"): for files that have no path, such as one dropped into the browser. The same
/// checks as `mix.sampler_load`; not journaled, since the bytes can be large.
pub fn load_sampler_bytes(e: &mut Engine, track: TrackId, name: &str, bytes: &[u8]) -> Result<Value> {
let before = e.session_arc();
let r = crate::cmd::sampler::attach(e, track, name, bytes, None, Vec::new())?;
e.push_undo("Load Sampler Sample", before);
Ok(r)
}
/// Resolve each movie referenced by the session file `session` ([`stored_path`]): keep the
/// resolved path when its file exists, else look for a file of the same name next to the session
/// (moved projects) and relink. A movie whose stored path is refused and has no such copy is
/// marked offline. Returns the movies still missing.
pub fn relink_videos(s: &mut Session, session: &Path) -> Vec<String> {
let session_dir = session.parent().unwrap_or(Path::new(""));
let mut missing = Vec::new();
for v in &mut s.videos {
v.offline = None;
let p = stored_path(&v.path, session);
// The UI opens `path` as it is: keep the checked path, not one relative to the working
// directory.
if let Ok(found) = &p
&& file_found(found)
{
v.path = found.to_string_lossy().into_owned();
continue;
}
let name = Path::new(&v.path).file_name().map(PathBuf::from).unwrap_or_default();
let near = [session_dir.join(&name), session_dir.join("Video Files").join(&name)]
.into_iter()
.find(|c| !name.as_os_str().is_empty() && file_found(c));
match (near, p) {
(Some(c), _) => v.path = c.to_string_lossy().into_owned(),
(None, Ok(p)) => missing.push(format!("{} (video)", p.display())),
(None, Err(why)) => {
missing.push(format!("{} (video): {why}", v.path));
v.offline = Some(why);
}
}
}
missing
}
#[cfg(test)]
mod rate_tests {
use super::*;
#[test]
fn a_tiny_sample_rate_is_refused_before_it_is_converted() {
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
assert!(at_rate(buf(1), 48_000, 1_000_000).is_none());
let ok = at_rate(buf(8_000), 48_000, 1_000_000).unwrap();
assert_eq!((ok.sample_rate, ok.frames()), (48_000, 600));
}
}

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