Compare commits

..
Author SHA1 Message Date
Brandon Thomas 5075da2f3e Merge remote-tracking branch 'origin/main' into land-993
# Conflicts:
#	crates/engine/src/score.rs
2026-10-29 21:59:42 -04:00
Brandon Thomas 52083e07f1 Merge pull request #1159 from Silvertree2010/fix/h3519-insert-time-and-cut-time-leave-k
Move key signatures with Insert Time and Cut Time
2026-10-29 21:55:21 -04:00
Brandon Thomas b46584cc30 Merge remote-tracking branch 'origin/main' into fix1159 2026-10-29 21:54:18 -04:00
Brandon Thomas 52b5cea05f Merge pull request #1134 from Silvertree2010/fix/h2909-cut-time-and-insert-time-leave-a
Shift selection memory location ends on Cut Time and Insert Time
2026-10-29 21:54:11 -04:00
Brandon Thomas 228fd37ab7 Merge remote-tracking branch 'origin/main' into fix1134
# Conflicts:
#	crates/engine/tests/it/edit_modes.rs
2026-10-29 21:53:13 -04:00
Brandon Thomas 60d18f225d Merge pull request #1095 from dotCooCoo/fix/strip-silence-scan-stops
Strip Silence: closing the window while Mix shows stops the rectangle scan
2026-10-29 21:52:38 -04:00
Brandon Thomas ba0c6e381f Merge pull request #1106 from dotCooCoo/fix/wasm-clippy-drop-non-drop
Engine: wasm32 clippy reports no drop_non_drop in autosave and backups
2026-10-29 21:52:35 -04:00
Brandon Thomas bcb872f1a3 Merge pull request #1103 from dotCooCoo/fix/updates-ui-wasm-dead-code
Web: Check for Updates opens its window, with no dead_code warnings in xtask wasm
2026-10-29 21:52:31 -04:00
Brandon Thomas b119e3adce Merge pull request #1077 from sanderdewijs/fix/playback-engine-command
setup.playback_engine / setup.hardware: apply device and buffer in the app, don't claim it headless
2026-10-29 21:52:27 -04:00
Brandon Thomas 0d8dde8294 Merge pull request #1181 from reoniazx/fix/dirty-state-comparison
Avoid cloning sessions when checking unsaved changes
2026-10-29 21:52:24 -04:00
Brandon Thomas 94fb7db790 Merge pull request #1179 from luigiPinna/fix/video-sample-cap-912
Route sequential H.264 sample reads through the 256 MiB cap
2026-10-29 21:52:20 -04:00
Brandon Thomas 3f5377c877 Merge pull request #1178 from luigiPinna/fix/vst3-bus-limits-904
Reject VST3 bus layouts beyond host limits instead of truncating
2026-10-29 21:52:16 -04:00
Brandon Thomas 3b87c6dcc2 Merge pull request #1026 from CloudCorpRecords/fix/folder-delete-shared-bus
track.delete: keep a folder's input bus when surviving tracks route through it
2026-10-29 21:52:12 -04:00
Brandon Thomas d2244d0765 Merge pull request #1172 from TheGalitube/fix/h1672-bars-beats-conversion-panics-on
Clamp the minimum beat before conversion
2026-10-29 21:52:09 -04:00
Brandon Thomas e2809001cf Merge pull request #1171 from TheGalitube/fix/h1407-velocity-ramps-miscalculate-note
Calculate velocity ramps across the full tick range
2026-10-29 21:52:05 -04:00
Brandon Thomas 92c925a1dc Merge pull request #1149 from ayozetr/test-878-matroska-sizes
Audio import: regression test for oversized Matroska element sizes (fixes #878)
2026-10-29 21:52:01 -04:00
Brandon Thomas 140e63fe09 Merge pull request #1148 from ayozetr/fix-895-stretch-render-budget
Bound the source span of stretch renders (fixes #895)
2026-10-29 21:51:58 -04:00
Brandon Thomas c3cf5fe236 Merge pull request #1145 from DAnaeto/fix/cli-keep-going-exit-status
Fix exit status after kept-going script errors
2026-10-29 21:51:55 -04:00
Brandon Thomas 1ce93ab2ea Merge pull request #1169 from Silvertree2010/fix/h3883-setup-current-timecode-overflows
Saturate and clamp session start in setup.current_timecode
2026-10-29 21:51:51 -04:00
Brandon Thomas dd1ac56430 Merge pull request #1182 from Silvertree2010/fix/h3996-h-264-slice-header-panics-with-i
Return Error::Invalid for overflowing H.264 slice header values
2026-10-29 21:51:47 -04:00
Brandon Thomas fda41ecd12 Merge pull request #1161 from Silvertree2010/fix/h3574-new-memory-location-during-playb
Fix stale end on a Memory Location added during playback
2026-10-29 21:51:43 -04:00
Brandon Thomas 6a10dfa19d Merge pull request #1160 from Silvertree2010/fix/h3562-mcp-server-sends-no-reply-to-an
Reply with Invalid Request to empty batches and non-object messages
2026-10-29 21:51:39 -04:00
Brandon Thomas 672f5ab767 Merge pull request #1153 from Silvertree2010/test/reference-algorithms
Check the rewritten DSP paths against the algorithms they replaced
2026-10-29 21:51:35 -04:00
Brandon Thomas 11ab6a999c Merge pull request #1141 from Silvertree2010/fix/1113-audio-io-info-reports-more-frame
Report the decoded length for AAC in MP4 in audio-io probe
2026-10-29 21:51:31 -04:00
Brandon Thomas b84d6c95d5 Merge pull request #1138 from Silvertree2010/fix/h3004-moving-several-midi-notes-left-p
Keep MIDI note spacing when moving notes left past tick 0
2026-10-29 21:51:28 -04:00
Brandon Thomas 1d99907d85 Merge pull request #1137 from Silvertree2010/fix/h3028-pasting-a-crossfaded-clip-pair-d
Keep crossfade overlaps when pasting clips
2026-10-29 21:51:24 -04:00
Brandon Thomas 57b3ae5357 Merge pull request #1123 from Silvertree2010/fix/h2893-elastic-warp-to-a-shorter-length
Clamp clip fades after elastic warp and remove warp
2026-10-29 21:51:21 -04:00
Brandon Thomas 2e6c519bef Merge pull request #1110 from Silvertree2010/fix/h2771-bwf-bext-fields-are-cut-in-the-m
Cut BWF bext text fields at UTF-8 character boundaries
2026-10-29 21:51:18 -04:00
Brandon Thomas adc7c13a37 Merge pull request #1062 from Silvertree2010/fix/h2436-moving-several-clips-left-past-t
Keep clip spacing when moving a group left past the session start
2026-10-29 21:51:14 -04:00
Brandon Thomas f5d7ca5d87 Merge pull request #1061 from Silvertree2010/fix/h2409-musicxml-svg-score-export-writes
Strip XML-forbidden characters from MusicXML/SVG score export
2026-10-29 21:51:10 -04:00
Brandon Thomas a6df32ffab Merge pull request #1025 from Silvertree2010/fix/h2054-session-text-export-counts-midi
Count only audio-track clips in session text export
2026-10-29 21:51:07 -04:00
Brandon Thomas 7b654fd1a3 Merge pull request #1024 from Silvertree2010/fix/h2052-min-secs-position-strings-are-mi
Reject out-of-range timecode fields so Min:Secs strings fall through
2026-10-29 21:51:03 -04:00
NetterSilverandClaude Haiku 5.5 f16e312fbc Return Error::Invalid for overflowing H.264 slice header values
Found while testing crates/video/src/h264/slice.rs:202-204 (SliceHeader::parse)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 14:54:30 +01:00
reoniazx 986fb951df Avoid cloning sessions for dirty-state comparisons 2026-10-29 20:19:47 +08:00
Luigi Pinna 5dbebaf617 Contributors: add luigiPinna to people.toml 2026-10-29 10:46:43 +01:00
Sander de WijsandClaude Opus 5.5 5c8654b2d7 Split the playback engine device test under the cognitive complexity limit
Upstream now denies clippy::cognitive_complexity (#1129). The test is
split into refusals, applying, and parity with ui.audio_device; the
assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:46:26 +01:00
Sander de WijsandClaude Opus 5.5 d97e5e7f16 Validate playback device command responses
Reject non-string device values before changing session or audio configuration, include bit depth in desktop responses, and report applied only for an open non-silent device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:39:42 +01:00
Sander de WijsandClaude Opus 5.5 a63b23adf7 Apply the playback engine's device and buffer in the desktop app
setup.playback_engine and setup.hardware stored device and buffer_size on the session and reported 1024 when nothing was set. Playback never read those values, so the open device stayed at its old buffer. In the app the commands now reopen the device through ui.audio_device and return the name, buffer and sample rate actually in use, while delay compensation still goes through the engine command. Headless keeps the stored request, returns applied: false, and leaves buffer_size null until one is set. A buffer is any integer from 16 to 8192 and is not rounded to a power of two; values outside that range error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:39:42 +01:00
Max MittelstädtandGPT-6-Luna 0ede26f5bc Clamp the minimum beat before conversion
Found while testing crates/time/src/tempo.rs:304

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-29 09:05:48 +00:00
Luigi Pinna 09444326d5 Reject VST3 bus layouts beyond host limits instead of truncating
Fixes #904
2026-10-29 09:42:56 +01:00
Luigi Pinna b609172db6 Route sequential H.264 sample reads through the 256 MiB cap
decode_h264 read each sample with Mp4File::read_sample directly, so a
sample table entry above 256 MiB was allocated and read in full when the
source was long enough. It now goes through Movie::read like the
keyframe and intra-frame paths, and an oversized sample is skipped like
any other unreadable access unit.

Fixes #912
2026-10-29 09:39:46 +01:00
Max MittelstädtandGPT-6-Luna 65e5443975 Calculate velocity ramps across the full tick range
Found while testing crates/midi/src/ops.rs:181

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-29 08:31:50 +00:00
NetterSilverandClaude Haiku 5.5 1c284e3703 Saturate and clamp session start in setup.current_timecode
Found while testing crates/engine/src/cmd/setup_more.rs:309-310 (current_timecode)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:08:00 +01:00
Dominic Anaeto 179f0c8c5d Merge main and preserve script failure status after CLI refactor 2026-10-28 22:55:08 -04:00
Dylan M. Taylor 8ca6939981 Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#1167)
* Document the aarch64 archive name in FreeBSD packaging

package.sh already honors CARGO_BUILD_TARGET: cargo builds for the target, the binaries are read
from target/<triple>/release, and the arch comes from the target rather than uname -m. Only the
header still described x86_64 alone, so update it.

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

* refactor(h264): split decoding work by operation

* refactor(h264): clarify prediction and filter inputs

* docs: require workspace lint inheritance

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

* docs: clarify crate lint placement

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

* refactor(mix): extract strip and routing stages

* refactor(ui): split edit window event handlers

* refactor(ui): clarify app and dialog actions

* refactor(ui): simplify mixer and panel workflows

* fix(ui): apply track resize only once
2026-10-28 11:48:06 -04:00
NetterSilverandClaude Haiku 5.5 e600e6bf64 Fix stale end on a Memory Location added during playback
Found while testing crates/engine/src/cmd/event.rs:305 (add_marker)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:06:40 +01:00
NetterSilverandClaude Haiku 5.5 90932afed0 Reply with Invalid Request to empty batches and non-object messages
Found while testing crates/automation/src/mcp.rs:201-205 (Server::handle) and :258-261 (Server::serve)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:16:19 +00:00
NetterSilverandClaude Haiku 5.5 50d0028e07 Move key signatures with Insert Time and Cut Time
Found while testing crates/engine/src/cmd/event.rs:147-196 (insert_time, cut_time); key_signatures in crates/model/src/session.rs:344

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:43:00 +00:00
dotCooCoo 1a20190149 Merge commit 'refs/check/mw-soundcraft-main' into fix/updates-ui-wasm-dead-code
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-28 02:22:29 -07:00
dotCooCoo c14dc61473 Merge commit 'refs/check/mw-soundcraft-main' into fix/strip-silence-scan-stops
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-28 01:42:36 -07: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
NetterSilverandClaude Opus 5.5 ceb3404c31 Check the rewritten DSP paths against the algorithms they replaced
True peak, the Maximizer window minimum, flush and GainRamp are compared
bit for bit with their previous implementations computed in the test, so
the checks hold whatever the platform libm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:58:58 +01:00
Ayoze Torres ed0aed5ccb Matroska regression test: assert each case reaches the read it targets
The codec-data file now has a valid Info and track up to its oversized
CodecPrivate, and must fail with the end-of-file error from that read;
the block file must probe successfully, so decoding reaches the cluster.
An earlier rejection by anything else would now fail the test instead
of passing it without exercising the oversized read.
2026-10-27 21:34:25 +00: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
Ayoze Torres 1aab8e449d Audio import: regression test for oversized Matroska element sizes (#878)
With symphonia 0.5, the Matroska reader allocated whatever size an
element claimed before reading it, so a 30-byte file whose EBML header
claims 2^48 bytes aborted the process on import, drag and drop or
session open. Since the bump to symphonia 0.6 such reads grow with the
data actually read (safe_read_into_boxed_slice), so the abort is gone
without a SoundCraft-side check.

Lock that in: the reported file, a track whose CodecPrivate claims 2^46
bytes and a SimpleBlock that claims 2^47 bytes must give an error (or,
for the block, a best-effort read of what is there) under every hint,
never an abort. The test passes with memory capped at 1 GB.
2026-10-27 20:52:22 +00:00
aiongg 662cf85c00 ci: use storytold/craft-common-actions at the merged installer-policy commit 2026-10-27 20:51:11 +00:00
Ayoze Torres c7a7cfa09b Bound the source span of stretch renders (#895)
render_stretched bounded only the output, at an hour. The source span it
reads in is the clip length over its stretch, which the 0.1..10 ratio
check lets reach ten hours, and it is held for every channel of the
source. A session's clip length and stretch are not trusted, so a
one-frame stereo file with a one-hour clip at stretch 0.1 made Remove
Pitch Shift allocate about 14 GB and abort, losing unsaved work.
Elastic Properties and TCE to Timeline go through the same code.

Both the source span and the output are now bounded by render_limit for
the source's channel count, like other offline renders, and the command
is refused before anything is allocated.
2026-10-27 20:48:57 +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
Dominic Anaeto 79c45a154d fix(cli): report kept-going script errors 2026-10-27 13:26:04 -04: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
NetterSilverandClaude Opus 5.5 9adcc1f389 Report the decoded length for AAC in MP4 in audio-io probe
Fixes #1113

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:29:22 +00: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
NetterSilverandClaude Haiku 5.5 795f1cb6ef Keep MIDI note spacing when moving notes left past tick 0
Found while testing crates/engine/src/cmd/midi.rs:128-131 (notes_move)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 11:34:36 +00:00
NetterSilverandClaude Opus 5.5 e631966cf7 Keep crossfade overlaps when pasting clips
Found while testing crates/engine/src/edit.rs:235-248 (paste, via place_clip at :252-258)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:21:59 +00:00
NetterSilverandClaude Haiku 5.5 45498c8268 Shift selection memory location ends on Cut Time and Insert Time
Found while testing crates/engine/src/cmd/event.rs:162-167 (insert_time) and :188-194 (cut_time)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 02:34:20 +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
NetterSilverandClaude Haiku 5.5 0d0d0abc27 Clamp clip fades after elastic warp and remove warp
Found while testing crates/engine/src/cmd/clip.rs:217-231 (elastic), also :249 (remove_warp)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 21:44:02 +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
NetterSilverandClaude Haiku 5.5 9dc26fa1ca Cut BWF bext text fields at UTF-8 character boundaries
Found while testing crates/audio-io/src/wav.rs:343-348 (put_fixed_str, used by bext_chunk)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 06:50:47 +01:00
Rene Turcios 7a9b3e38e1 Merge main into fix/folder-delete-shared-bus
Bring the branch up to date with upstream main (64 commits). The only
overlap was crates/engine/src/cmd/track.rs, resolved by keeping the
upstream refactor that extracts the surviving-parent walk into
surviving_parent, and keeping this branch's change on top of it: the
removed-bus computation now computes the replacement from that helper
and still drops the bus only when bus_used_beyond_folder says no
surviving track routes through it, so a shared folder bus and every
route to it survive the folder delete.

Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com>
2026-10-25 21:16:40 -07:00
dotCooCoo 9a99c82d2c Engine: wasm32 clippy reports no drop_non_drop in autosave and backups
On wasm32-unknown-unknown, std::fs::File has no Drop, so clippy with
-D warnings reports the three explicit drops of a File or Option<File>
in soundcraft-engine as drop_non_drop errors: Autosave::discard,
autosave::leftovers and io::copy_new.

Autosave::discard now releases the lock by setting the field to None,
which closes the file on every target. The drops in leftovers and
copy_new carry #[cfg(not(target_arch = "wasm32"))], as the DirBuilder
mode call in Autosave::create carries #[cfg(unix)]. The desktop builds
close the same files at the same points as before.
2026-10-25 21:08:33 -07: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
dotCooCoo 0c7212854a Web: Check for Updates opens its window, with no dead_code warnings in xtask wasm
SoundApp::logic and SoundApp::ui called updates_ui::tick and
updates_ui::show only off wasm32, so `cargo xtask wasm` reported both
functions as never used. On the web, SoundCraft > Check for Updates…
sets the window's message ("Update check is not supported in the web
browser.") but nothing drew the window.

Both functions already have wasm32 branches, so the calls now run on
every target, as effectcraft's lib.rs calls them.
2026-10-25 17:36:40 -07: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
dotCooCoo ee55acbc93 Strip Silence: closing the window while Mix shows stops the rectangle scan
Only the Edit window dropped the Strip Silence rectangles and their scan
when Strip Silence closed. With the Mix window showing, a scan for old
values ran to the end and strip_scan_running() stayed true.
dialogs::show, which runs every frame, now drops them whenever neither
Strip Silence nor its Rename Selected Clips dialog is open.

edit.strip_silence_preview, strip_silence_scan and strip_settings move
from cmd/edit.rs to a new cmd/strip_silence.rs with its own specs(), as
AGENTS.md asks for new commands. edit.strip_silence reads its settings
through the same strip_settings.
2026-10-25 14:13:49 -07:00
Samuel Bacaro e294aa48c0 score: keep the bar duration when the item cap drops notes
A 64/1 bar can hold 256 sixteenths, more than MAX_ITEMS_PER_MEASURE (128). The
cap used to truncate the item list and drop the remaining duration, so MusicXML
wrote an underfull measure and SVG divided positions by the shortened total,
moving the kept notes across the bar.

The discarded suffix is now replaced with representable rests. The cap shrinks
the retained prefix further when the rests would not fit, and a final truncate
still bounds a hostile meter whose whole-bar rests exceed the cap.

Regression test: item_cap_keeps_the_measure_duration (fails on the old
truncate-only code, passes with rests).
2026-10-25 09:55:14 -03:00
Samuel Bacaro 021caab499 score: make the MusicXML oversize test reach the measure check
The 1 KiB budget failed on the part-list check, so nothing exercised the MusicXML
per-measure budget. Measure the header (parts with no measures) and the full
document, pick a budget between them, and assert that the header fits while the
completed document is refused, so the test proves the refusal comes from the
measures. The 1 KiB run stays as the header-refusal case, and the SVG case is
unchanged.
2026-10-25 09:40:30 -03:00
Samuel Bacaro fa77e03e16 score: apply every byte-budget and per-part cap in the same place
Addresses the review threads on #993:

- MAX_NOTES_PER_PART: the layout loop stops collecting past 100k notes
  per part, so the raw note vector cannot grow unboundedly regardless
  of clip count.
- fill: a pitch the chord cap rejects no longer extends the onset end
  (e.0), so dropped pitches do not inflate chord duration; a retained
  pitch that reappears with a longer duration still does.
- musicxml now checks the budget before the per-measure loop (covers an
  oversized title/composer, escaped, even with zero measures), after the
  part-list, and at the end, so no growth path is unchecked.
- svg now checks every measure (rest-only or missing measures cannot
  grow the output untouched past the budget), every staff, every system
  and the final footer, so staff/clef/bracket growth is capped too.
- over_budget takes the budget as a parameter and the budgeted entry
  points stay separate so tests pass a tiny budget instead of building
  256 MiB. The oversize test shrank from ~256 MiB to ~64 KiB and the
  new header test checks the title/composer escape path directly.

Regression tests.
2026-10-25 09:23:40 -03:00
Samuel Bacaro ccd3fe7ca2 Bound Print Score and MusicXML output size
MAX_MEASURES limited the number of measures but not the items per
measure, pitches per chord, or total output bytes, so a hostile session
could produce an unbounded document (a 10M-note session became a ~20 GB
SVG and aborted).

- Limit items per measure and pitches per chord in layout/fill.
- Give svg() and musicxml() a 256 MiB byte budget; over it they return
  an error instead of growing without bound.
- write_score surfaces that error to the caller.

Fixes #921
2026-10-25 09:23:40 -03:00
NetterSilverandClaude Haiku 5.5 9f049ac7d0 Keep clip spacing when moving a group left past the session start
Found while testing crates/engine/src/edit.rs:486-489 (move_clips), reached via edit.move_clips in crates/engine/src/cmd/edit.rs:935

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:51:21 +00:00
NetterSilverandClaude Haiku 5.5 7509b64c5c Strip XML-forbidden characters from MusicXML/SVG score export
Found while testing crates/engine/src/score.rs:216-218 (xml_escape), used by musicxml() and svg()

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:39:26 +01:00
Rene Turcios 8385ed94c6 track.delete: keep a folder's input bus when surviving tracks route through it
owned_folder_bus treats any bus a folder takes as input as the folder's
own, but track.input or a session file can point a folder at any bus.
Deleting such a folder removed the bus and repaired every route to it:
inputs set to none, outputs rerouted, sends cleared. Point a folder at
the demo session's shared Verb bus and deleting it wiped every reverb
send in the session.

Remove the bus only when nothing that survives the delete routes
through it: an input, a send target, or an output from outside the
folder's own subtree, whose outputs the delete already reroutes to the
parent folder's bus.

Fixes #914

Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com>
2026-10-24 15:05:38 +00:00
NetterSilverandClaude Haiku 5.5 8f23afe462 Count only audio-track clips in session text export
Found while testing crates/engine/src/inspect.rs:116-120 (session_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:00:54 +02:00
NetterSilverandClaude Haiku 5.5 6d86a52310 Reject out-of-range timecode fields so Min:Secs strings fall through
Found while testing crates/time/src/timecode.rs:189-211 (Timecode::parse), reached via crates/engine/src/cmd/mod.rs:275-287 (position_param_raw)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:57:29 +02:00
271 changed files with 32178 additions and 24593 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);
}
}
}
+460
View File
@@ -0,0 +1,460 @@
//! 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,
/// Continue after command errors and finish requested output, then exit nonzero if any command failed.
/// Malformed JSON stops immediately
#[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));
}
}
+51
View File
@@ -0,0 +1,51 @@
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("script finished with command errors")]
ScriptFailed,
#[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));
}
}
+114
View File
@@ -0,0 +1,114 @@
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;
let mut failed = false;
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}");
failed = true;
}
}
}
finish(&mut e, &args.engine, out)?;
if failed { Err(CliError::ScriptFailed) } else { Ok(()) }
}
#[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");
}
}
+567
View File
@@ -0,0 +1,567 @@
//! 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();
// Kept-going line errors stay bare; a final error summary supplies the failing status.
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()), (1, "track.new: {\"tracks\":[1]}\n"));
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\nerror: script finished with command errors\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>'",
);
}
/// Native file paths round-trip on filesystems that accept non-Unicode names. macOS rejects
/// these names with EILSEQ before the tool can write them. Run in-process: no shell is needed.
#[cfg(all(unix, not(target_os = "macos")))]
#[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 _ = std::fs::remove_dir_all(&dir);
}
/// Engine output paths enter JSON, so invalid Unicode must be refused even on a filesystem
/// that cannot create such a file (including macOS).
#[cfg(unix)]
#[test]
fn non_unicode_session_outputs_are_rejected() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let dir = sandbox("nonunicode-outputs");
for (flag, name) in [("--bounce", b"caf\xe9.wav".as_slice()), ("--save", b"caf\xe9.scraft".as_slice())] {
let path = dir.join(OsString::from_vec(name.to_vec()));
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = soundcraft_cli::run(vec!["run".into(), "--demo".into(), flag.into(), path.into_os_string()], &mut out, &mut err);
let err = String::from_utf8_lossy(&err);
assert_eq!(code, 1, "{err}");
assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}");
assert!(out.is_empty());
}
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
+7
View File
@@ -1,5 +1,12 @@
//! 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;
mod script_status;
@@ -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>'",
);
}
@@ -0,0 +1,90 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let sequence = NEXT.fetch_add(1, Ordering::Relaxed);
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("soundcraft-script-status-{}-{stamp}-{sequence}", std::process::id()));
std::fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn invoke(dir: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).current_dir(dir).env("SOUNDCRAFT_NO_PREFS", "1").args(args).output().unwrap()
}
#[test]
fn keep_going_runs_later_commands_and_finishes_but_reports_failure() {
let dir = Scratch::new();
std::fs::write(
dir.0.join("audit.txt"),
"audit.deliberately_invalid {}\ntrack.new {\"name\":\"After failure\",\"count\":1,\"format\":\"stereo\"}\n",
)
.unwrap();
let stop = invoke(&dir.0, &["script", "audit.txt", "--save", "stopped.scraft"]);
assert_eq!(stop.status.code(), Some(1));
assert!(String::from_utf8_lossy(&stop.stderr).contains("audit.deliberately_invalid"));
assert!(!String::from_utf8_lossy(&stop.stdout).contains("track.new:"));
assert!(!dir.0.join("stopped.scraft").exists());
let keep = invoke(
&dir.0,
&["script", "audit.txt", "--keep-going", "--bounce", "after.wav", "--start", "0", "--end", "0.01", "--save", "after.scraft", "--inspect"],
);
assert_eq!(keep.status.code(), Some(1), "{}", String::from_utf8_lossy(&keep.stderr));
assert!(String::from_utf8_lossy(&keep.stderr).contains("audit.deliberately_invalid"));
let stdout = String::from_utf8_lossy(&keep.stdout);
assert!(stdout.contains("track.new:") && stdout.contains("After failure"), "{stdout}");
assert!(dir.0.join("after.wav").is_file());
let info = invoke(&dir.0, &["info", "after.scraft"]);
assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));
assert!(String::from_utf8_lossy(&info.stdout).contains("After failure"));
}
#[test]
fn successful_script_and_parsing_or_finish_failures_keep_their_status() {
let dir = Scratch::new();
std::fs::write(dir.0.join("good.txt"), "track.new {\"name\":\"Good\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
let success = invoke(&dir.0, &["script", "good.txt", "--keep-going"]);
assert!(success.status.success(), "{}", String::from_utf8_lossy(&success.stderr));
std::fs::write(dir.0.join("bad.txt"), "track.new {bad\ntrack.new {\"name\":\"Too late\"}\n").unwrap();
let malformed = invoke(&dir.0, &["script", "bad.txt", "--keep-going", "--save", "malformed.scraft"]);
assert_eq!(malformed.status.code(), Some(1));
assert!(String::from_utf8_lossy(&malformed.stderr).contains("bad JSON"));
assert!(!String::from_utf8_lossy(&malformed.stdout).contains("track.new:"));
assert!(!dir.0.join("malformed.scraft").exists());
std::fs::write(dir.0.join("parent"), "not a directory").unwrap();
for (option, path) in [("--save", "parent/out.scraft"), ("--bounce", "parent/out.wav")] {
let fail = invoke(&dir.0, &["script", "good.txt", "--keep-going", option, path]);
assert_eq!(fail.status.code(), Some(1), "{option}: {}", String::from_utf8_lossy(&fail.stderr));
assert!(String::from_utf8_lossy(&fail.stderr).contains("parent"));
}
std::fs::write(dir.0.join("engine.txt"), "session.save {}\ntrack.new {\"name\":\"After engine failure\",\"count\":1}\n").unwrap();
let fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "engine.scraft"]);
assert_eq!(fail.status.code(), Some(1));
assert!(String::from_utf8_lossy(&fail.stderr).contains("session.save"));
assert!(String::from_utf8_lossy(&fail.stdout).contains("track.new:"));
assert!(dir.0.join("engine.scraft").is_file());
let both_fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "parent/out.scraft"]);
assert_eq!(both_fail.status.code(), Some(1));
let errors = String::from_utf8_lossy(&both_fail.stderr);
assert!(errors.contains("session.save") && errors.contains("parent"), "{errors}");
assert!(!errors.contains("script finished with command errors"), "{errors}");
}
+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
+4
View File
@@ -61,3 +61,7 @@ display_name = "Matt Smith-Burlage"
[people.ProdByBuddha]
sync_github_name = true
[people.luigiPinna]
real_name = "Luigi Pinna"
sync_github_name = true
+3 -1
View File
@@ -20,7 +20,9 @@ log.workspace = true
unsafe_code = "deny"
[lints.clippy]
cognitive_complexity = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
too_many_arguments = "deny"
type_complexity = "allow"
+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}");
}
}
}
+35 -2
View File
@@ -580,13 +580,46 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
Ok((info, AudioBuffer { sample_rate, channels: planar }))
}
/// Frames symphonia's AAC decoder yields for every packet; it supports no other frame length.
const AAC_FRAMES_PER_PACKET: u64 = 1024;
/// Whether `track` of `reader` is AAC in MP4. The MP4 reader reports the stts total as the length,
/// but the decoder yields a whole frame per packet (sample durations are not applied), so a last
/// sample whose duration differs from a frame makes the two disagree.
fn is_mp4_aac(reader: &dyn FormatReader, track_id: u32) -> bool {
reader.format_info().short_name == "isomp4"
&& reader.tracks().iter().find(|t| t.id == track_id).and_then(audio_params).is_some_and(|p| p.codec == CODEC_ID_AAC)
}
/// The decoded length of AAC in MP4: count the track's packets without decoding them.
fn mp4_aac_frames(reader: &mut dyn FormatReader, track_id: u32) -> Result<u64> {
let mut frames: u64 = 0;
loop {
match reader.next_packet() {
Ok(Some(packet)) if packet.track_id == track_id => frames = frames.saturating_add(AAC_FRAMES_PER_PACKET),
Ok(Some(_)) => {}
Ok(None) => break,
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => break,
Err(e) => {
demux_failure(frames, e)?;
break;
}
}
}
Ok(frames)
}
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
guarded(|| {
let reader = match open(bytes, detected)? {
let mut reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader,
Opened::Empty(info) => return Ok(info),
};
let (_, info) = info_of(reader.as_ref(), detected)?;
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
if info.frames > 0 && info.channels > 0 && is_mp4_aac(reader.as_ref(), track_id) {
info.frames = mp4_aac_frames(reader.as_mut(), track_id)?;
}
if info.frames > 0 && info.channels > 0 {
return Ok(info);
}
+4 -1
View File
@@ -342,7 +342,10 @@ pub(crate) fn read_channel_mask(b: &[u8]) -> Option<u32> {
fn put_fixed_str(out: &mut Vec<u8>, s: &str, len: usize) {
let bytes = s.as_bytes();
let n = bytes.len().min(len);
let mut n = bytes.len().min(len);
while !s.is_char_boundary(n) {
n -= 1;
}
out.extend_from_slice(bytes.get(..n).unwrap_or_default());
out.resize(out.len() + (len - n), 0);
}
+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");
}
+3 -1
View File
@@ -1,8 +1,10 @@
//! 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 mp4_length;
mod roundtrip;
mod stream;
mod wav_empty_data;
+64
View File
@@ -208,6 +208,70 @@ fn caf_sizes_beyond_the_file_are_errors_not_aborts() {
assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets")));
}
/// An EBML element: `id`, then `size` as an 8-byte vint (so any size up to 2^56 − 2 fits, and
/// `u64::MAX` writes the reserved "unknown size"), then `body`.
fn ebml(id: &[u8], size: u64, body: &[u8]) -> Vec<u8> {
let size = if size == u64::MAX { 0x01FF_FFFF_FFFF_FFFF } else { 0x0100_0000_0000_0000 | size };
[id, &size.to_be_bytes(), body].concat()
}
/// The EBML header of a Matroska file.
fn ebml_header() -> Vec<u8> {
let doc_type = ebml(&[0x42, 0x82], 8, b"matroska");
ebml(&[0x1A, 0x45, 0xDF, 0xA3], doc_type.len() as u64, &doc_type)
}
/// #878: symphonia's Matroska reader takes element sizes from the file. With symphonia 0.5 a size
/// far beyond the file was allocated up front and aborted the process (no `catch_unwind` can catch
/// an allocation failure); since 0.6 such reads grow with the data actually read. Keep it that way
/// for the header, for codec data and for block frames.
#[test]
fn matroska_sizes_beyond_the_file_are_errors_not_aborts() {
let huge = 1u64 << 47;
// The 30-byte file from the report: EBML header and DocType sizes of 2^48 − 1 and 2^47.
let reported = [
0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x42, 0x82, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6D,
0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61,
];
let segment = |body: Vec<u8>| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat();
let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat();
let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info);
// A valid 16-bit PCM track entry's children, followed by `extra`.
let pcm = |extra: &[u8]| {
let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat();
[
ebml(&[0xD7], 1, &[1]),
ebml(&[0x73, 0xC5], 1, &[1]),
ebml(&[0x83], 1, &[2]),
ebml(&[0x86], 13, b"A_PCM/INT/LIT"),
ebml(&[0xE1], audio.len() as u64, &audio),
extra.to_vec(),
]
.concat()
};
// That track with a CodecPrivate claiming 2^46 bytes, inside parents that claim even more: the
// segment is valid up to that read, which runs out of file.
let track = ebml(&[0xAE], huge, &pcm(&ebml(&[0x63, 0xA2], huge >> 1, &[0; 16])));
let codec_data = segment([info.clone(), ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)].concat());
// The plain track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data.
let track = pcm(&[]);
let track = ebml(&[0xAE], track.len() as u64, &track);
let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat());
let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], track.len() as u64, &track), block].concat());
let out_of_file = |r: Result<_, AudioError>| matches!(r, Err(AudioError::Malformed(m)) if m.contains("end of file"));
for hint in [None, Some("mkv"), Some("wav")] {
assert!(probe(&reported, hint).is_err(), "{hint:?}");
assert!(decode(&reported, hint).is_err(), "{hint:?}");
// Refused at the oversized read itself, not by anything before it.
assert!(out_of_file(probe(&codec_data, hint).map(|_| ())), "{hint:?}: {:?}", probe(&codec_data, hint).err());
assert!(out_of_file(decode(&codec_data, hint).map(|_| ())), "{hint:?}");
// The file and its track open, so decoding reaches the block; the block reader stops at the
// end of the file, a best-effort read of what is there.
assert!(probe(&cluster, hint).is_ok(), "{hint:?}: {:?}", probe(&cluster, hint).err());
assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}");
}
}
#[test]
fn well_formed_caf_still_reads() {
let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
+61
View File
@@ -0,0 +1,61 @@
//! The length `probe` reports for AAC in MP4 is the length `decode` produces (issue #1113).
use soundcraft_audio_io::{decode, probe};
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
}
/// A full atom (version 0, no flags) whose body is `fields` as big-endian u32s.
fn full(kind: &[u8; 4], fields: &[u32]) -> Vec<u8> {
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
atom(kind, &body)
}
/// An MPEG-4 descriptor with a one-byte length.
fn descriptor(tag: u8, body: &[u8]) -> Vec<u8> {
[&[tag, body.len() as u8][..], body].concat()
}
/// A silent AAC-LC raw frame for one channel: an SCE with `max_sfb` 0, then ID_END.
const SILENT_SCE: [u8; 4] = [0x00, 0xC8, 0x00, 0x07];
/// A mono 48 kHz AAC-LC MP4 without an edit list: `packets` frames, the last lasting `last_dur` in stts.
fn aac_mp4(packets: u32, last_dur: u32) -> Vec<u8> {
// AudioSpecificConfig: AAC-LC, 48 kHz, one channel.
let dsi = descriptor(5, &[0x11, 0x88]);
let dcd = descriptor(4, &[&[0x40, 0x15, 0, 0, 0][..], &[0; 8], &dsi].concat());
let es = descriptor(3, &[&[0, 1, 0][..], &dcd, &descriptor(6, &[2])].concat());
let esds = atom(b"esds", &[&[0; 4][..], &es].concat());
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
let entry = atom(b"mp4a", &[&base[..], &(48_000u32 << 16).to_be_bytes(), &esds].concat());
let total = (packets - 1) * 1024 + last_dur;
let moov = |offset: u32| {
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat());
let stbl = [
stsd,
full(b"stts", &[2, packets - 1, 1024, 1, last_dur]),
full(b"stsc", &[1, 1, packets, 1]),
full(b"stsz", &[SILENT_SCE.len() as u32, packets]),
full(b"stco", &[1, offset]),
];
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
let mdia = [full(b"mdhd", &[0, 0, 48_000, total, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, total, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
};
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
[ftyp, moov(offset), atom(b"mdat", &SILENT_SCE.repeat(packets as usize))].concat()
}
#[test]
fn aac_in_mp4_probe_length_matches_the_decoded_length() {
// A last sample lasting until the track end (longer than a packet), and a control with a short one.
for last_dur in [1145, 546] {
let file = aac_mp4(10, last_dur);
let (_, buf) = decode(&file, Some("m4a")).unwrap();
assert_eq!(buf.frames(), 10 * 1024, "decoded frames, last stts duration {last_dur}");
assert_eq!(probe(&file, Some("m4a")).unwrap().frames, buf.frames() as u64, "probed frames, last stts duration {last_dur}");
}
}
+10
View File
@@ -316,3 +316,13 @@ fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
let frames = u64::MAX / 4;
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
}
#[test]
fn bwf_truncation_keeps_utf8_boundaries() {
let src = sine(48_000, 1, 10);
let bwf = BwfInfo { originator: format!("{}é", "a".repeat(31)), description: format!("{}é", "d".repeat(255)), ..BwfInfo::default() };
let bytes = encode(&src, &EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }).unwrap();
let out = probe(&bytes, None).unwrap().bwf.unwrap();
assert_eq!(out.originator, "a".repeat(31));
assert_eq!(out.description, "d".repeat(255));
}
+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 || {
+44 -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!({}), &[]),
@@ -198,6 +199,9 @@ impl<B: Backend> Server<B> {
/// Handle one JSON-RPC message; returns the reply line (None for notifications).
pub fn handle(&mut self, msg: &Value) -> Option<Value> {
if !msg.is_object() {
return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request"}}));
}
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(json!({}));
@@ -254,6 +258,9 @@ impl<B: Backend> Server<B> {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(Value::Array(batch)) if batch.is_empty() => {
Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request: empty batch"}}))
}
Ok(Value::Array(batch)) => {
let replies: Vec<Value> = batch.iter().filter_map(|m| self.handle(m)).collect();
if replies.is_empty() { None } else { Some(Value::Array(replies)) }
@@ -269,3 +276,39 @@ impl<B: Backend> Server<B> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Headless;
use soundcraft_engine::Engine;
fn reply(line: &str) -> String {
let mut server = Server::new(Headless::new(Engine::default()));
let mut out = Vec::new();
server.serve(line.as_bytes(), &mut out).unwrap();
String::from_utf8(out).unwrap()
}
#[test]
fn invalid_requests_get_an_invalid_request_error() {
for input in ["[]", "42", "[1]", "\"x\""] {
let text = reply(input);
assert_eq!(text.lines().count(), 1, "input {input}: reply was {text:?}");
let v: Value = serde_json::from_str(text.trim()).unwrap();
let e = match &v {
Value::Array(a) => a[0].clone(),
other => other.clone(),
};
assert_eq!(e["error"]["code"], -32600, "input {input}");
assert_eq!(e["id"], Value::Null, "input {input}");
}
}
#[test]
fn notifications_still_get_no_reply() {
assert_eq!(reply(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), "");
let v: Value = serde_json::from_str(reply(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).trim()).unwrap();
assert_eq!(v["id"], 1);
}
}
+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);
+89 -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;
@@ -381,3 +395,63 @@ impl LoudnessMeter {
gain_to_db(self.true_peak)
}
}
#[cfg(test)]
mod tests {
use super::*;
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()
}
/// True peak as first written: the history shifted every sample, each phase summed on its own.
fn true_peak_reference(coefs: &[[f32; TP_PHASES]; TP_TAPS], x: &[f32]) -> f32 {
let mut h = [0.0f32; TP_TAPS];
let mut tp = 0.0f32;
for v in x.iter().map(|v| if v.is_finite() { *v } else { 0.0 }) {
h.copy_within(0..TP_TAPS - 1, 1);
h[0] = v;
let mut peak = v.abs();
for p in 0..TP_PHASES {
let y: f32 = coefs.iter().zip(h.iter()).map(|(c, b)| c[p] * b).sum();
peak = peak.max(y.abs());
}
if peak > tp {
tp = peak;
}
}
tp
}
#[test]
fn true_peak_matches_the_shifted_history() {
let sr = 48_000.0;
let len = 20_000;
let mut l: Vec<f32> =
(0..len).map(|i| 0.6 * (std::f32::consts::TAU * 0.25 * i as f32 + 0.7).sin()).zip(noise(len, 1, 0.3)).map(|(a, b)| a + b).collect();
let r = noise(len, 2, 0.9);
l[123] = f32::NAN;
l[4_567] = f32::INFINITY;
for block in [1, 7, 480, len] {
let mut m = LoudnessMeter::new(sr, 2);
let want = true_peak_reference(&m.tp_coefs, &l).max(true_peak_reference(&m.tp_coefs, &r));
for at in (0..len).step_by(block) {
let n = block.min(len - at);
m.process(&[l[at..at + n].to_vec(), r[at..at + n].to_vec()], n);
}
assert_eq!(m.true_peak.to_bits(), want.to_bits(), "block {block}");
m.reset();
let quiet = noise(500, 3, 0.1);
m.process(&[quiet.clone(), quiet.clone()], 500);
assert_eq!(m.true_peak.to_bits(), true_peak_reference(&m.tp_coefs, &quiet).to_bits(), "after reset, block {block}");
}
}
}
+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);
}
+89 -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();
@@ -632,3 +671,44 @@ impl Plugin for Maximizer {
self.gr_meter
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The window minimum as first written: the last `la` requests in a ring, scanned whole.
#[test]
fn window_min_matches_a_scan_of_the_last_requests() {
let mut x = 0x2545_f491_4f6c_dd1du64;
let mut next = move || {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x
};
for la in [1usize, 2, 3, 7, 64, 480] {
let mut m = Maximizer::new();
m.win = vec![(0, 1.0); la];
let mut ring = vec![1.0f32; la];
let mut at = 0;
for i in 0..6_000 {
if i == 3_000 {
m.reset();
ring.iter_mut().for_each(|v| *v = 1.0);
at = 0;
}
let r = next();
let req = match r % 16 {
0 => f32::NAN,
1 => 1.0,
2 => 0.25,
_ => (r >> 40) as f32 / (1u64 << 24) as f32 * 1.5,
};
ring[at] = req;
at = (at + 1) % la;
let want = ring.iter().copied().fold(1.0f32, f32::min);
assert_eq!(m.window_min(req, la).to_bits(), want.to_bits(), "la {la}, sample {i}");
}
}
}
}
+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}");
}
}
}
+16 -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.
@@ -309,3 +310,17 @@ pub(crate) fn sort_events<T: Copy>(ev: &mut [T], key: impl Fn(&T) -> usize) {
pub(crate) fn note_hz(note: f32) -> f32 {
440.0 * 2f32.powf((note - 69.0) / 12.0)
}
#[cfg(test)]
mod tests {
#[test]
fn flush_matches_the_finite_and_magnitude_test() {
let edge = 1.0e-25f32.to_bits();
let special = [0, 0x8000_0000, 1, edge - 1, edge, edge + 1, 0x7f7f_ffff, 0x7f80_0000, 0xff80_0000, 0x7fc0_0000, 0xffc0_0001];
for b in (0..=u32::MAX).step_by(997).chain(special).chain(special.map(|b| b | 0x8000_0000)) {
let x = f32::from_bits(b);
let want = if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 };
assert_eq!(super::flush(x).to_bits(), want.to_bits(), "{b:#x}");
}
}
}
+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");
}
+4 -3
View File
@@ -255,7 +255,7 @@ impl Autosave {
/// Delete the folder: the session closed with nothing left to recover.
fn discard(mut self) {
drop(self.lock.take());
self.lock = None;
let _ = std::fs::remove_dir_all(&self.dir);
}
}
@@ -286,6 +286,7 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok());
let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified());
let recording = dir.join(RECORDING).is_file();
#[cfg(not(target_arch = "wasm32"))]
drop(lock);
match saved {
Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {
@@ -310,7 +311,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 +536,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();
@@ -28,6 +28,7 @@ mod plugin_state;
mod query;
pub(crate) mod sampler;
mod setup_more;
mod strip_silence;
mod surround;
mod tab;
mod track;
@@ -63,7 +64,7 @@ mod panic_probe {
pub use file::bounce_job;
pub(crate) use edit::remember_selection;
pub use edit::strip_silence_scan;
pub use strip_silence::strip_silence_scan;
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
/// `Err(reason)` = disabled.
@@ -160,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(sampler::specs());
v.extend(video::specs());
v.extend(zoom_toggle::specs());
v.extend(strip_silence::specs());
#[cfg(test)]
v.extend(panic_probe::specs());
v
+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 };
+23 -1
View File
@@ -228,6 +228,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
nc.content = soundcraft_model::ClipContent::Audio { source: src, offset: 0 };
nc.length = out_len;
nc.stretch = 1.0;
nc.clamp_fades();
super::more_util::replace_clip(s, t, *id, nc);
rendered += 1;
}
@@ -246,7 +247,7 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
if let Some((src, off, len)) = orig {
c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
c.length = len.max(1);
c.fade_out.len = c.fade_out.len.min(c.length);
c.clamp_fades();
n += 1;
}
}
@@ -496,6 +497,27 @@ mod tests {
}
}
#[test]
fn warping_and_unwarping_keep_fades_within_the_clip() {
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
if let Some(c) = e.session_mut().find_clip_mut(cid) {
c.fade_in.len = 20_000;
c.fade_out.len = 20_000;
}
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap();
let c = e.session().find_clip(cid).unwrap().1.clone();
assert!(c.fade_in.len <= c.length && c.fade_out.len <= c.length, "{} {} {}", c.length, c.fade_in.len, c.fade_out.len);
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap();
if let Some(c) = e.session_mut().find_clip_mut(cid) {
c.fade_in.len = 40_000;
}
e.execute("clip.remove_warp", &json!({"clips": [cid.0]})).unwrap();
let c = e.session().find_clip(cid).unwrap().1.clone();
assert!(c.fade_in.len <= c.length, "{} {}", c.length, c.fade_in.len);
}
#[test]
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
let mut e = crate::demo::demo_engine();
+3 -42
View File
@@ -1,7 +1,7 @@
//! Edit menu commands.
use super::{
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection,
has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, has_clipboard, has_range, has_selection, has_tracks,
i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
};
use soundcraft_time::Samples;
@@ -125,15 +125,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_range,
strip_silence
),
cmd!(
query "edit.strip_silence_preview",
"Strip Silence Rectangles",
[],
None,
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
has_range,
strip_silence_preview
),
cmd!(
"edit.fades_create",
"Create...",
@@ -821,38 +812,8 @@ fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"tracks": tracks.len()}))
}
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
/// command in errors.
fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
let tracks = tracks_required(e, cmd, p)?;
let r = range_param(e, cmd, p)?;
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
let sr = e.session().sample_rate;
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
}
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
/// can be started.
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
}
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
let found = strip_silence_scan(e, p)?.run()?;
let tracks: Vec<Value> =
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
Ok(json!({"tracks": tracks}))
}
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
let (tracks, r, thr, min_len, (pre, post)) = strip_settings(e, "edit.strip_silence", p)?;
let (tracks, r, thr, min_len, (pre, post)) = super::strip_silence::strip_settings(e, "edit.strip_silence", p)?;
let mode = match p.get("mode") {
None => crate::io::StripMode::Strip,
Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?,
+34 -1
View File
@@ -163,6 +163,13 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
if m.start >= r.start {
m.start += r.len();
m.end += r.len();
} else if m.end > r.start {
m.end += r.len();
}
}
for (at, _) in &mut s.key_signatures {
if *at >= r.start {
*at = at.saturating_add(r.len());
}
}
Ok(json!({"inserted": r.len()}))
@@ -190,6 +197,16 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
if m.start >= r.end {
m.start -= r.len();
m.end -= r.len();
} else if m.end >= r.end {
m.end -= r.len();
} else if m.end > r.start {
m.end = r.start;
}
}
s.key_signatures.retain(|(at, _)| !r.contains(*at));
for (at, _) in &mut s.key_signatures {
if *at >= r.end {
*at -= r.len();
}
}
Ok(json!({"removed": r.len()}))
@@ -302,7 +319,10 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result<Value> {
(None, Some(a), Some(b)) => (a, b),
(None, Some(p), None) | (None, None, Some(p)) => (p, p),
_ if kind == MarkerKind::Selection => (sel.start, sel.end),
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
_ => {
let point = if e.transport.playing { e.transport.position } else { sel.start };
(point, point)
}
};
let s = e.session_mut();
let n = s.markers.len() + 1;
@@ -473,6 +493,19 @@ mod tests {
assert_eq!(find(&c), Some((1000, 2000)));
}
/// A plain marker added during playback is a point at the playhead, whatever the edit selection is.
#[test]
fn markers_add_while_playing_is_a_point_at_the_playhead() {
let mut e = demo_engine();
e.session_mut().edit.selection.start = 48_000;
e.session_mut().edit.selection.end = 48_000;
e.transport.playing = true;
e.transport.position = 4800;
let r = e.execute("markers.add", &json!({"name": "M"})).unwrap();
let m = e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end));
assert_eq!(m, Some((4800, 4800)));
}
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
#[test]
fn edit_marker_sets_documented_color() {
+12 -417
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,
@@ -336,7 +335,11 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
return Err(bad(id, "no MIDI or instrument tracks to notate"));
}
let setup = crate::score::ScoreSetup::from_session(e.session());
let (text, ext) = if svg { (crate::score::svg(&parts, &setup), "svg") } else { (crate::score::musicxml(&parts, &setup), "musicxml") };
let (text, ext) = if svg {
(crate::score::svg(&parts, &setup).map_err(|m| bad(id, m))?, "svg")
} else {
(crate::score::musicxml(&parts, &setup).map_err(|m| bad(id, m))?, "musicxml")
};
let path = match str_param(p, "path") {
Some(p) => p.to_string(),
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
@@ -347,412 +350,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);
}

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