mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-03 00:41:02 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fb33855dc |
@@ -1,26 +0,0 @@
|
||||
# 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
|
||||
@@ -21,27 +21,3 @@ 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')
|
||||
|
||||
@@ -19,7 +19,6 @@ jobs:
|
||||
# The shared workflow creates the draft release, which needs write access to contents.
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -31,5 +31,3 @@ jobs:
|
||||
with:
|
||||
app-name: soundcraft
|
||||
app-dir: SoundCraft
|
||||
install-relative-path: Programs/ArtCraft/SoundCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft-apps folder, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
|
||||
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
|
||||
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
|
||||
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
|
||||
|
||||
## ⚠️ Assets: the absolute rule
|
||||
@@ -20,7 +22,7 @@ SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation tar
|
||||
Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l <id>`), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names.
|
||||
|
||||
## Never crash
|
||||
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.**
|
||||
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
- No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API.
|
||||
- The same exception, same rules, for `soundcraft-vst3-host` (`crates/vst3-host`), the isolated unsafe helper crate for VST3 plugin FFI (COM vtables via the `vst3` bindings, module loading, host-side COM objects).
|
||||
@@ -65,7 +67,7 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc
|
||||
`cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit.
|
||||
|
||||
## Release
|
||||
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/releasing.md`.
|
||||
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry.
|
||||
@@ -79,13 +81,11 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file)
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
+8
-9
@@ -39,14 +39,14 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts
|
||||
|
||||
| Path | Author | Source | License |
|
||||
|---|---|---|---|
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
|
||||
| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 |
|
||||
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
|
||||
@@ -55,4 +55,3 @@ 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 |
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
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.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+38
-460
@@ -200,56 +200,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
@@ -277,12 +227,6 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -794,71 +738,12 @@ dependencies = [
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a76abbdb2907f6fd97fb6bc0b7be96b77d328f2dd9669d1075cc03369ed22154"
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_complete"
|
||||
version = "4.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f"
|
||||
dependencies = [
|
||||
"clap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "clap_mangen"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"roff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.3"
|
||||
@@ -868,15 +753,6 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -897,12 +773,6 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
@@ -1064,40 +934,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_normalizer",
|
||||
"icu_plurals",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"icu_provider_adapters",
|
||||
"icu_provider_blob",
|
||||
"log",
|
||||
"objc2-foundation 0.3.2",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
"windows-registry",
|
||||
"writeable",
|
||||
]
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
|
||||
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -1105,35 +947,26 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"egui",
|
||||
"epaint",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
"skrifa",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -1627,17 +1460,6 @@ version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -2035,12 +1857,6 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2071,9 +1887,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
@@ -2083,111 +1899,44 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||
dependencies = [
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.3.0"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"zerovec",
|
||||
]
|
||||
checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
@@ -2200,21 +1949,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.3.0"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.1"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"postcard",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -2222,30 +1968,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_adapters"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
|
||||
dependencies = [
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_blob"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
|
||||
dependencies = [
|
||||
"icu_provider",
|
||||
"postcard",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -2312,12 +2034,6 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -2543,9 +2259,6 @@ name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -3193,12 +2906,6 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.5"
|
||||
@@ -3481,24 +3188,12 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -3823,12 +3518,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roff"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -4012,17 +3701,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -4297,10 +3975,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"craft-update",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4310,8 +3987,6 @@ dependencies = [
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-automation",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-playback",
|
||||
"soundcraft-ui-egui",
|
||||
@@ -4321,7 +3996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4331,7 +4006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4343,20 +4018,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-time",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4369,24 +4041,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-automation",
|
||||
"soundcraft-dsp",
|
||||
"soundcraft-engine",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4397,7 +4063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4420,7 +4086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4430,7 +4096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4446,7 +4112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4459,7 +4125,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4474,7 +4140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4484,7 +4150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -4513,7 +4179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4525,7 +4191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4541,7 +4207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4549,7 +4215,6 @@ dependencies = [
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-model",
|
||||
"soundcraft-playback",
|
||||
@@ -4586,18 +4251,6 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4834,18 +4487,6 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
@@ -4962,31 +4603,6 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.5"
|
||||
@@ -4994,7 +4610,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5232,12 +4847,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
@@ -5250,12 +4859,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.29.0"
|
||||
@@ -5870,17 +5473,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -6031,7 +5623,6 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -6109,20 +5700,11 @@ dependencies = [
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
@@ -6189,7 +5771,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6377,11 +5959,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6390,7 +5969,6 @@ version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
@@ -6409,9 +5987,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.3"
|
||||
version = "9.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
|
||||
checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
||||
+6
-12
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.13.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -29,36 +29,30 @@ 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 = ["mp1", "mp2", "mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b" }
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", 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).
|
||||
|
||||
@@ -286,34 +286,3 @@ The Font Software may be sold as part of a larger software package but no copy o
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -168,16 +168,6 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
|
||||
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
|
||||
new tracks. **Setup › Keyboard Shortcuts** lists them all.
|
||||
|
||||
In **New Tracks**, each row has its own count, format, type, Samples/Ticks timebase and name.
|
||||
Use **+** to add a row below or **−** to remove it; **Create** makes all rows in order, with one
|
||||
Undo step (up to 128 tracks per creation). The last row stays available. The dropdown widths
|
||||
fit their longest option and remain fixed when the selection changes.
|
||||
|
||||
On macOS, **⌘←/→** changes the active row's format, **⌘↑/↓** its type (also with **⌃**),
|
||||
and **⌥⌘↑/↓** its timebase. **⇧⌃⌘↓/↑** adds/removes the active row, also with **⌥**;
|
||||
**⇧⌘↓/↑** works too. Windows/Linux use **Ctrl** for **⌘**. The active row follows the
|
||||
field you click. A new row starts with its count focused and selected; Enter creates and Escape cancels.
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
|
||||
@@ -194,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` | `1`: no preferences read or written and no log file (agents' test runs). |
|
||||
| `SOUNDCRAFT_NO_PREFS` | 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
|
||||
@@ -226,9 +216,8 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo
|
||||
soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
|
||||
```
|
||||
|
||||
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.
|
||||
See [`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
|
||||
@@ -350,15 +339,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+4
-6
@@ -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 (Cancel Zoom Toggle, 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
|
||||
> **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,7 +122,6 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change.
|
||||
- **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.
|
||||
@@ -157,8 +156,8 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
|
||||
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
|
||||
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
|
||||
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the progress docs
|
||||
set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
|
||||
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
|
||||
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
|
||||
localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours,
|
||||
tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session.
|
||||
- **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media
|
||||
@@ -177,7 +176,6 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged |
|
||||
| 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 |
|
||||
@@ -206,6 +204,6 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added |
|
||||
| 2026-10-10 | major | Progress docs restructured: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
|
||||
| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list |
|
||||
| 2026-10-06 | minor | Alpha and parity estimates |
|
||||
|
||||
@@ -6,10 +6,6 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "soundcraft_cli"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "soundcraft-cli"
|
||||
path = "src/main.rs"
|
||||
@@ -22,12 +18,4 @@ 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
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 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")]);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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 },
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
//! `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(())
|
||||
}
|
||||
@@ -1,12 +1,684 @@
|
||||
//! `soundcraft-cli`: SoundCraft without a window. See the library crate for the commands.
|
||||
|
||||
//! `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
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
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.
|
||||
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.
|
||||
soundcraft_engine::shutdown_plugin_hosts();
|
||||
std::process::exit(code);
|
||||
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, ¶ms) {
|
||||
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, ¶ms) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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, ¶ms) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,567 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -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"], "a value is required");
|
||||
fails(&["--automation-read-root"], "a value is required");
|
||||
fails(&["--automation-write-root", "--demo"], "a value is required");
|
||||
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-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root="], "a value is required");
|
||||
fails(&["--automation-write-root="], "needs a non-empty folder");
|
||||
// A misspelled root flag must not start an unconfined server.
|
||||
fails(&["--automation-read-roots", dir], "unexpected argument '--automation-read-roots'");
|
||||
fails(&["--automation-read-roots", dir], "unknown mcp option");
|
||||
// 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(&["mcp", "--help"]);
|
||||
let o = run(&["--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
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
//! 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;
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
//! 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();
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
//! 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>'",
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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}");
|
||||
}
|
||||
@@ -58,11 +58,7 @@ 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!("unexpected argument '{bad}' found")),
|
||||
"{bad}: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("unknown option {bad}")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "{bad} should not write the output");
|
||||
}
|
||||
}
|
||||
@@ -75,11 +71,7 @@ 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!("a value is required for '{bad} ")),
|
||||
"{bad}: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("{bad} needs a value")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "{bad} should not write the output");
|
||||
}
|
||||
}
|
||||
@@ -112,11 +104,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("a value is required for '--bounce <OUT>'"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--bounce needs a value"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
|
||||
let o = run(&["run", "--cmd", "session.new", "--bounces", out.to_str().unwrap()]);
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unexpected argument '--bounces' found"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown option --bounces"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "nothing should be bounced");
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ 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 }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! 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;
|
||||
@@ -68,6 +67,8 @@ 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 {
|
||||
@@ -93,11 +94,11 @@ impl Purpose {
|
||||
}
|
||||
}
|
||||
|
||||
fn filter(self) -> (&'static str, Vec<&'static str>) {
|
||||
fn filter(self) -> (&'static str, &'static [&'static str]) {
|
||||
match self {
|
||||
Self::Session => ("SoundCraft Session", SESSION_EXTS.to_vec()),
|
||||
Self::Audio => ("Audio", FileFormat::readable_extensions()),
|
||||
Self::Midi => ("MIDI", MIDI_EXTS.to_vec()),
|
||||
Self::Session => ("SoundCraft Session", SESSION_EXTS),
|
||||
Self::Audio => ("Audio", AUDIO_EXTS),
|
||||
Self::Midi => ("MIDI", MIDI_EXTS),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +130,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();
|
||||
@@ -227,15 +228,17 @@ 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();
|
||||
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase());
|
||||
opts.set_type(match ext.as_deref() {
|
||||
opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).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",
|
||||
ext => ext.and_then(FileFormat::from_extension).map_or("application/octet-stream", FileFormat::mime_type),
|
||||
_ => "application/octet-stream",
|
||||
});
|
||||
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)?;
|
||||
|
||||
@@ -16,24 +16,17 @@ 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
|
||||
log.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Preserve X11 fallback while enabling font-free Wayland client-side controls.
|
||||
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd.workspace = true
|
||||
|
||||
|
||||
@@ -17,13 +17,21 @@ enum Platform {
|
||||
Other,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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> {
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
} else if cfg!(windows) {
|
||||
@@ -31,12 +39,11 @@ pub fn resolve_config_dir(get: impl Fn(&str) -> Option<OsString>) -> Option<Path
|
||||
} else {
|
||||
Platform::Other
|
||||
};
|
||||
let exe = std::env::current_exe().ok();
|
||||
config_dir_with(platform, get, exe.as_deref().and_then(Path::parent))
|
||||
config_dir_with(platform, |name| std::env::var_os(name), exe_dir)
|
||||
}
|
||||
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
if let Some(dir) = env(crate::env::CONFIG_DIR) {
|
||||
if let Some(dir) = env("SOUNDCRAFT_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()) {
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
//! 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)]);
|
||||
}
|
||||
}
|
||||
@@ -413,12 +413,13 @@ impl log::Log for AppLogger {
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `rust_log`, the `RUST_LOG` value, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// Install the logger (filter from `RUST_LOG`, 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(rust_log: Option<&str>) -> Option<&'static AppLogger> {
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let filter = Filter::parse(rust_log.unwrap_or(DEFAULT_FILTER));
|
||||
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 max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
|
||||
+85
-48
@@ -1,6 +1,6 @@
|
||||
//! SoundCraft desktop app.
|
||||
//!
|
||||
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`; `soundcraft --help` lists the flags and `SOUNDCRAFT_CONTROL_PORT`.
|
||||
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`
|
||||
//!
|
||||
//! `--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,22 +13,17 @@
|
||||
|
||||
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>, Env, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
struct App(SoundApp, Option<&'static logging::AppLogger>, #[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) {
|
||||
@@ -48,20 +43,20 @@ impl eframe::App for App {
|
||||
logger.report_audio_thread();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.3 {
|
||||
if let Some(menu) = &mut self.2 {
|
||||
menu.process(&mut self.0);
|
||||
}
|
||||
self.0.guarded(|app| app.logic(ctx));
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.3 {
|
||||
if let Some(menu) = &mut self.2 {
|
||||
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.
|
||||
for f in self.0.take_dropped_files() {
|
||||
if !f.path().as_os_str().is_empty() {
|
||||
dialogs::open_path(&mut self.0, f.path());
|
||||
}
|
||||
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);
|
||||
}
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -77,7 +72,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.2, &self.0.ui);
|
||||
save_prefs(&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.
|
||||
@@ -103,7 +98,7 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
self.0.close_autosave();
|
||||
save_prefs(&self.2, &self.0.ui);
|
||||
save_prefs(&self.0.ui);
|
||||
if let Some(logger) = self.1 {
|
||||
logger.report_audio_thread();
|
||||
}
|
||||
@@ -114,16 +109,42 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path(env: &Env) -> Option<std::path::PathBuf> {
|
||||
env.config_dir.as_ref().map(|b| b.join("ui.json"))
|
||||
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"))
|
||||
}
|
||||
|
||||
/// Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
|
||||
fn load_prefs(env: &Env) -> Option<UiState> {
|
||||
if !env.prefs_enabled() {
|
||||
fn prefs_enabled() -> bool {
|
||||
std::env::var_os("SOUNDCRAFT_NO_PREFS").is_none()
|
||||
}
|
||||
|
||||
fn load_prefs() -> Option<UiState> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let path = prefs_path(env)?;
|
||||
let path = prefs_path()?;
|
||||
match config::load_ui(&path) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
@@ -133,11 +154,11 @@ fn load_prefs(env: &Env) -> Option<UiState> {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_prefs(env: &Env, ui: &UiState) {
|
||||
if !env.prefs_enabled() {
|
||||
fn save_prefs(ui: &UiState) {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(path) = prefs_path(env)
|
||||
if let Some(path) = prefs_path()
|
||||
&& let Err(error) = config::save_ui(&path, ui)
|
||||
{
|
||||
log::warn!("{error}");
|
||||
@@ -151,13 +172,17 @@ 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", &FileFormat::readable_extensions()),
|
||||
_ => d.add_filter(
|
||||
"Audio",
|
||||
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"],
|
||||
),
|
||||
};
|
||||
d.pick_file()
|
||||
d.pick_file().map(|p| p.to_string_lossy().into_owned())
|
||||
})),
|
||||
pick_save: Some(Box::new(|purpose: &str, path: &std::path::Path| {
|
||||
pick_save: Some(Box::new(|purpose: &str, default: &str| {
|
||||
let path = std::path::Path::new(default);
|
||||
let mut d = match purpose {
|
||||
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &FileFormat::writable_extensions()),
|
||||
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &["wav", "aif", "aiff", "flac"]),
|
||||
_ => rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]),
|
||||
};
|
||||
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
|
||||
@@ -166,9 +191,11 @@ 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()
|
||||
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())
|
||||
})),
|
||||
pick_folder: Some(Box::new(|start: &std::path::Path| rfd::FileDialog::new().set_directory(start).pick_folder())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,27 +212,39 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&soundcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
// Engine) is captured; see `logging`.
|
||||
let logger = logging::install(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());
|
||||
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);
|
||||
// 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 env.config_dir.as_deref() {
|
||||
_ if !env.prefs_enabled() => logger.no_file(),
|
||||
match config_dir() {
|
||||
_ if !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}"),
|
||||
@@ -218,7 +257,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
|
||||
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
||||
let prefs = load_prefs(&env).unwrap_or_default();
|
||||
let prefs = load_prefs().unwrap_or_default();
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("SoundCraft")
|
||||
@@ -239,7 +278,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 env.use_xwayland(std::env::var_os("DISPLAY").as_ref()) {
|
||||
if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("SOUNDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
@@ -253,10 +292,9 @@ 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.set_system_fonts(env.system_fonts());
|
||||
app.autosave_dir = env.config_dir.as_ref().map(|d| d.join("Autosave"));
|
||||
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
|
||||
app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default();
|
||||
app.preset_dir = env.config_dir.as_ref().map(|d| d.join("Presets"));
|
||||
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
|
||||
app.extra.ui_scale = prefs.ui_scale;
|
||||
app.ui = prefs;
|
||||
if let Some(port) = control_port {
|
||||
@@ -264,7 +302,7 @@ fn main() -> eframe::Result {
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
for f in &files {
|
||||
dialogs::open_path(&mut app, f);
|
||||
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")]
|
||||
@@ -281,7 +319,6 @@ fn main() -> eframe::Result {
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
logger,
|
||||
env,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
//! 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
//! 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}");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# SoundCraft app icon (placeholder)
|
||||
|
||||
**A nightingale singing**, as an engraving-style head-and-shoulders portrait in the Crafting Apps'
|
||||
owl-template framing: a full-bleed colour field, the bird
|
||||
owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colour field, the bird
|
||||
in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off
|
||||
the bottom and right edges, three song arcs leaving the bill.
|
||||
|
||||
|
||||
@@ -2,8 +2,4 @@ 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
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# Contributor names for the About window.
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -58,14 +61,3 @@ display_name = "Matt Smith-Burlage"
|
||||
|
||||
[people.ProdByBuddha]
|
||||
sync_github_name = true
|
||||
|
||||
[people.luigiPinna]
|
||||
real_name = "Luigi Pinna"
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.kk376]
|
||||
real_name = "Kushagra Kumar"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -13,7 +13,7 @@ serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# An isolated unsafe helper crate, like soundcraft-vst3-host: this crate
|
||||
# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-vst3-host: this crate
|
||||
# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere
|
||||
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||
[lints.rust]
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! values are the Audio Unit's own (plain) values, ranges and units, so no mapping is needed.
|
||||
//! Boolean parameters become toggles, indexed ones starting at 0 become choices.
|
||||
//!
|
||||
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate:
|
||||
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate (craftrules never-crash):
|
||||
//! `unsafe` is denied crate-wide and allowed only in the private `ffi` module (hand-written
|
||||
//! AudioToolbox / CoreFoundation declarations). The public API is safe, returns
|
||||
//! `Result`/`Option` and never panics. Limits of in-process hosting: an Audio Unit that itself
|
||||
|
||||
@@ -20,7 +20,7 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
|
||||
// Whether the packet table lists each packet's size and frame count (variable per packet).
|
||||
let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false);
|
||||
while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) {
|
||||
let body = pos + 12;
|
||||
let (body, size) = (pos + 12, size as i64);
|
||||
let used = match kind {
|
||||
b"desc" if size == 32 => {
|
||||
lists_sizes = be_u32(b, body + 16) == Some(0);
|
||||
@@ -28,12 +28,12 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
|
||||
described = true;
|
||||
Some(32)
|
||||
}
|
||||
b"data" if size == u64::MAX => Some(4),
|
||||
b"data" if size == -1 => Some(4),
|
||||
b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20),
|
||||
b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?,
|
||||
b"kuki" if size > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
|
||||
b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
|
||||
b"desc" | b"pakt" | b"chan" => None,
|
||||
_ => Some(size),
|
||||
_ => u64::try_from(size).ok(),
|
||||
};
|
||||
match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) {
|
||||
Some(next) if next < b.len() => pos = next,
|
||||
@@ -85,14 +85,3 @@ fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn rejects_cookie_size_above_signed_range() {
|
||||
let mut bytes = b"caff\0\x01\0\0kuki".to_vec();
|
||||
bytes.extend_from_slice(&(1u64 << 63).to_be_bytes());
|
||||
|
||||
assert!(super::check(&bytes, 0).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,13 +511,10 @@ pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
|
||||
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
|
||||
}
|
||||
|
||||
/// Whether a vendor string or comment in a (little-endian) Vorbis comment block claims more than
|
||||
/// the block holds.
|
||||
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
|
||||
/// The vendor string is skipped unread.
|
||||
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
|
||||
let Some(vendor) = le_u32(b, body).filter(|_| body.saturating_add(4) <= end) else { return false };
|
||||
if vendor as usize > end - (body + 4) {
|
||||
return true;
|
||||
}
|
||||
let Some(vendor) = le_u32(b, body) else { return false };
|
||||
let at = (body + 4).saturating_add(vendor as usize);
|
||||
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
|
||||
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
|
||||
|
||||
+27
-102
@@ -61,110 +61,9 @@ 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 {
|
||||
@@ -293,9 +192,22 @@ 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.and_then(FileFormat::from_extension).unwrap_or(FileFormat::Other))
|
||||
detect_by_magic(bytes, 0).unwrap_or_else(|| ext_hint.map(detect_by_ext).unwrap_or(FileFormat::Other))
|
||||
}
|
||||
|
||||
/// Read a file's format, sample rate, channel count, length and BWF metadata.
|
||||
@@ -358,6 +270,19 @@ 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)
|
||||
|
||||
@@ -159,17 +159,10 @@ impl Quantizer {
|
||||
v += self.uniform() - self.uniform();
|
||||
}
|
||||
let max = scale - 1.0;
|
||||
round_half_away(v.clamp(-scale, max)) as i32
|
||||
v.round().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 {
|
||||
@@ -242,38 +235,3 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,14 +24,14 @@ pub struct Peaks {
|
||||
struct Block {
|
||||
min: f32,
|
||||
max: f32,
|
||||
squares: f64,
|
||||
squares: f32,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
|
||||
|
||||
fn point(s: f32) -> Block {
|
||||
Block { min: s, max: s, squares: if s.is_finite() { f64::from(s) * f64::from(s) } else { 0.0 } }
|
||||
Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } }
|
||||
}
|
||||
|
||||
fn of(samples: &[f32]) -> Block {
|
||||
@@ -146,7 +146,7 @@ impl Peaks {
|
||||
.map(|w| {
|
||||
w.map(|(a, b)| {
|
||||
let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len()));
|
||||
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f64).sqrt() as f32
|
||||
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt()
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -215,13 +215,6 @@ mod tests {
|
||||
assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_large_samples_keep_finite_rms() {
|
||||
let samples = vec![1e20_f32; BLOCK];
|
||||
let peaks = Peaks::build(&samples);
|
||||
assert_eq!(peaks.rms_spans(&samples, 0.0, BLOCK as f64, 0.0, 1), vec![Some(1e20)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rms_spans_match_the_samples() {
|
||||
let x = signal(20_000);
|
||||
|
||||
@@ -580,46 +580,13 @@ 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 mut reader = match open(bytes, detected)? {
|
||||
let reader = match open(bytes, detected)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => return Ok(info),
|
||||
};
|
||||
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)?;
|
||||
}
|
||||
let (_, info) = info_of(reader.as_ref(), detected)?;
|
||||
if info.frames > 0 && info.channels > 0 {
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
@@ -342,10 +342,7 @@ 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 mut n = bytes.len().min(len);
|
||||
while !s.is_char_boundary(n) {
|
||||
n -= 1;
|
||||
}
|
||||
let n = bytes.len().min(len);
|
||||
out.extend_from_slice(bytes.get(..n).unwrap_or_default());
|
||||
out.resize(out.len() + (len - n), 0);
|
||||
}
|
||||
@@ -503,7 +500,6 @@ pub(crate) fn header(
|
||||
) -> Result<Vec<u8>> {
|
||||
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
|
||||
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
|
||||
let byte_rate = sample_rate.checked_mul(u32::from(block_align)).ok_or_else(|| AudioError::Encode("WAV byte rate overflow".into()))?;
|
||||
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
|
||||
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
|
||||
@@ -512,7 +508,7 @@ pub(crate) fn header(
|
||||
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
|
||||
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
|
||||
fmt.extend_from_slice(&block_align.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
if extensible {
|
||||
@@ -561,14 +557,3 @@ pub(crate) fn header(
|
||||
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::header;
|
||||
|
||||
#[test]
|
||||
fn rejects_byte_rate_overflow() {
|
||||
let result = header(2, 1_100_000_000, (16, false), None, None, 0, false);
|
||||
assert!(result.is_err(), "overflowing byte rate was accepted");
|
||||
}
|
||||
}
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,51 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod decode_read_list;
|
||||
mod file_format;
|
||||
mod encode_format;
|
||||
mod float_headroom;
|
||||
mod malformed;
|
||||
mod mp4_length;
|
||||
mod roundtrip;
|
||||
mod stream;
|
||||
mod wav_empty_data;
|
||||
|
||||
@@ -110,15 +110,6 @@ fn zero_channels_is_malformed() {
|
||||
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_negative_channel_count_is_malformed() {
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 8] = 0x80;
|
||||
aiff[comm + 9] = 0;
|
||||
assert!(probe(&aiff, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_sample_rate_is_malformed() {
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
|
||||
@@ -217,70 +208,6 @@ 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();
|
||||
@@ -375,7 +302,6 @@ fn packets_longer_than_any_codec_uses_are_refused() {
|
||||
}
|
||||
}
|
||||
|
||||
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
|
||||
#[test]
|
||||
fn flac_metadata_fields_beyond_their_block_are_refused() {
|
||||
let with_block = |kind: u8, body: &[u8]| {
|
||||
@@ -386,8 +312,7 @@ fn flac_metadata_fields_beyond_their_block_are_refused() {
|
||||
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
|
||||
assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300);
|
||||
let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat();
|
||||
let vendor_overflow = [&0x7fff_fff0u32.to_le_bytes()[..], &0u32.to_le_bytes()].concat();
|
||||
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment), with_block(4, &vendor_overflow)] {
|
||||
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] {
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
@@ -316,13 +316,3 @@ 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));
|
||||
}
|
||||
|
||||
@@ -9,15 +9,9 @@ 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
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
+53
-150
@@ -5,19 +5,14 @@
|
||||
//! 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::fmt;
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -84,151 +79,64 @@ 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<Connection>,
|
||||
conn: Option<(TcpStream, BufReader<TcpStream>)>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// 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 }
|
||||
/// `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 }
|
||||
}
|
||||
|
||||
/// 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)?)),
|
||||
fn connect(&mut self) -> Result<(), String> {
|
||||
if self.conn.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
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, (RemoteError, bool)> {
|
||||
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (String, bool)> {
|
||||
self.connect().map_err(|e| (e, false))?;
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
let (w, r) = self.connection().map_err(|e| (e, false))?;
|
||||
writeln!(w, "{line}").map_err(|e| (e.into(), false))?;
|
||||
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 mut buf = String::new();
|
||||
r.read_line(&mut buf).map_err(|e| (e.into(), true))?;
|
||||
r.read_line(&mut buf).map_err(|e| (e.to_string(), true))?;
|
||||
if buf.is_empty() {
|
||||
return Err((RemoteError::Closed, true));
|
||||
return Err(("connection closed".into(), true));
|
||||
}
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.into(), true))?;
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.to_string(), true))?;
|
||||
if v.get("ok").and_then(Value::as_bool).unwrap_or(false) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err((RemoteError::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()), true))
|
||||
Err((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, ¶ms) {
|
||||
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, ¶ms).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.
|
||||
@@ -238,7 +146,19 @@ fn is_read_only(method: &str) -> bool {
|
||||
|
||||
impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
self.request(method, params).map_err(|e| e.to_string())
|
||||
match self.roundtrip(method, ¶ms) {
|
||||
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, ¶ms).map_err(|(e, _)| e)
|
||||
}
|
||||
Err((e, _)) => Err(e),
|
||||
}
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
true
|
||||
@@ -265,25 +185,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remote_reports_unreachable() {
|
||||
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}");
|
||||
let mut r = Remote::new("127.0.0.1:1");
|
||||
assert!(r.call("session.inspect", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_resolves_hostnames() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let mut r = Remote::new(&ControlTarget { host: Some("localhost".into()), port });
|
||||
assert!(r.connection().is_ok());
|
||||
let mut r = Remote::new(&format!("localhost:{port}"));
|
||||
assert_eq!(r.connect(), Ok(()));
|
||||
}
|
||||
|
||||
/// Fake app: accepts one connection per entry in `replies`, counts the request line it reads,
|
||||
/// then either drops the connection (`false`) or answers ok (`true`).
|
||||
fn fake_app(replies: Vec<bool>) -> (ControlTarget, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
fn fake_app(replies: Vec<bool>) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() };
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
@@ -317,19 +235,4 @@ mod tests {
|
||||
assert!(r.call("session.inspect", json!({})).is_ok());
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_returns_an_app_error_unchanged() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() };
|
||||
std::thread::spawn(move || {
|
||||
let Ok((mut sock, _)) = listener.accept() else { return };
|
||||
let mut line = String::new();
|
||||
let _ = BufReader::new(sock.try_clone().unwrap()).read_line(&mut line);
|
||||
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": false, "error": "transport.locate: nothing to reset"}));
|
||||
});
|
||||
let mut r = Remote::new(&addr);
|
||||
let err = r.call("engine.execute", json!({"command": "transport.locate"})).unwrap_err();
|
||||
assert_eq!(err, "transport.locate: nothing to reset");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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";
|
||||
@@ -71,7 +70,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": FileFormat::writable_extensions()}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
|
||||
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": ["wav", "aiff", "flac"]}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
|
||||
&["path"],
|
||||
),
|
||||
tool("parity", "Feature-parity report against the incumbent's menu catalog.", json!({}), &[]),
|
||||
@@ -199,9 +198,6 @@ 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!({}));
|
||||
@@ -258,9 +254,6 @@ 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)) }
|
||||
@@ -276,39 +269,3 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ harness = false
|
||||
clap-sys = "0.5"
|
||||
libloading = "0.9"
|
||||
|
||||
# The isolated unsafe helper crate: this crate opts out of the workspace
|
||||
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
|
||||
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
|
||||
# FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||
[lints.rust]
|
||||
|
||||
@@ -54,7 +54,7 @@ pub(crate) use events::Event;
|
||||
#[cfg(test)]
|
||||
use events::transport_event;
|
||||
pub(crate) use gui::GuiLink;
|
||||
pub(crate) use instance::{Instance, NotePort, PortBuffers, PortInfo, ProcessInput, RawParam};
|
||||
pub(crate) use instance::{Instance, NotePort, PortBuffers, PortInfo, RawParam};
|
||||
|
||||
/// Most plugins one factory may report (hostile-input cap).
|
||||
const MAX_PLUGINS_PER_FACTORY: u32 = 1024;
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---- loaded binaries -----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---- events --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---- editor (clap.gui) ---------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---- host side -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -2,12 +2,7 @@ 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,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
// ---- audio buffers -------------------------------------------------------------------------
|
||||
|
||||
@@ -317,8 +312,16 @@ impl Instance {
|
||||
|
||||
/// 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;
|
||||
pub fn process(
|
||||
&mut self,
|
||||
frames: usize,
|
||||
steady_time: i64,
|
||||
inputs: &mut PortBuffers,
|
||||
outputs: &mut PortBuffers,
|
||||
events: &[Event],
|
||||
offset: u32,
|
||||
transport: Option<&Transport>,
|
||||
) -> Result<i32, ClapError> {
|
||||
if !self.active {
|
||||
return Err(ClapError::Process("not active".into()));
|
||||
}
|
||||
@@ -383,16 +386,6 @@ impl Instance {
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! plugins exactly like the built-in ones. Plugin ids are `clap:<CLAP plugin id>`; parameter ids
|
||||
//! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`].
|
||||
//!
|
||||
//! This is SoundCraft's single isolated `unsafe` crate: `unsafe` is
|
||||
//! This is SoundCraft's single isolated `unsafe` crate (craftrules never-crash): `unsafe` is
|
||||
//! denied crate-wide and allowed only in the private `ffi` module. The public API is safe, returns
|
||||
//! `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that itself crashes
|
||||
//! (segfault) takes the process down with it; we cannot sandbox native code.
|
||||
|
||||
@@ -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, ProcessInput};
|
||||
use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers};
|
||||
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,15 +231,7 @@ 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(ProcessInput {
|
||||
frames: n,
|
||||
steady_time: self.steady,
|
||||
inputs: &mut self.inputs,
|
||||
outputs: &mut self.outputs,
|
||||
events,
|
||||
offset,
|
||||
transport: self.transport.as_ref(),
|
||||
});
|
||||
let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset, 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
-89
@@ -150,9 +150,8 @@ pub struct LoudnessMeter {
|
||||
sub_filled: usize,
|
||||
hist_count: Vec<u64>,
|
||||
hist_energy: Vec<f64>,
|
||||
tp_coefs: [[f32; TP_PHASES]; TP_TAPS],
|
||||
tp_hist: Vec<[f32; 2 * TP_TAPS]>,
|
||||
tp_pos: Vec<usize>,
|
||||
tp_coefs: [[f32; TP_TAPS]; TP_PHASES],
|
||||
tp_hist: Vec<[f32; TP_TAPS]>,
|
||||
true_peak: f32,
|
||||
max_momentary: f32,
|
||||
}
|
||||
@@ -198,7 +197,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_PHASES]; TP_TAPS];
|
||||
let mut tp_coefs = [[0.0f32; TP_TAPS]; TP_PHASES];
|
||||
let total = TP_TAPS * TP_PHASES;
|
||||
let center = (total as f64 - 1.0) / 2.0;
|
||||
for i in 0..total {
|
||||
@@ -206,14 +205,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(|tap| tap.get_mut(i % TP_PHASES)) {
|
||||
if let Some(slot) = tp_coefs.get_mut(i % TP_PHASES).and_then(|ph| ph.get_mut(i / TP_PHASES)) {
|
||||
*slot = (sinc * w) as f32;
|
||||
}
|
||||
}
|
||||
for p in 0..TP_PHASES {
|
||||
let s: f32 = tp_coefs.iter().filter_map(|tap| tap.get(p)).sum();
|
||||
for ph in &mut tp_coefs {
|
||||
let s: f32 = ph.iter().sum();
|
||||
if s.abs() > 1e-6 {
|
||||
tp_coefs.iter_mut().filter_map(|tap| tap.get_mut(p)).for_each(|c| *c /= s);
|
||||
ph.iter_mut().for_each(|c| *c /= s);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
@@ -229,8 +228,7 @@ impl LoudnessMeter {
|
||||
hist_count: vec![0; HIST_BINS],
|
||||
hist_energy: vec![0.0; HIST_BINS],
|
||||
tp_coefs,
|
||||
tp_hist: vec![[0.0; 2 * TP_TAPS]; channels],
|
||||
tp_pos: vec![0; channels],
|
||||
tp_hist: vec![[0.0; TP_TAPS]; channels],
|
||||
true_peak: 0.0,
|
||||
max_momentary: MIN_DB,
|
||||
}
|
||||
@@ -259,8 +257,7 @@ 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; 2 * TP_TAPS]);
|
||||
self.tp_pos.iter_mut().for_each(|p| *p = 0);
|
||||
self.tp_hist.iter_mut().for_each(|h| *h = [0.0; TP_TAPS]);
|
||||
self.true_peak = 0.0;
|
||||
self.max_momentary = MIN_DB;
|
||||
}
|
||||
@@ -288,24 +285,13 @@ impl LoudnessMeter {
|
||||
}
|
||||
|
||||
fn true_peak_sample(&mut self, c: usize, x: f32) {
|
||||
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 Some(h) = self.tp_hist.get_mut(c) else { return };
|
||||
h.copy_within(0..TP_TAPS - 1, 1);
|
||||
h[0] = x;
|
||||
let mut peak = x.abs();
|
||||
for v in y {
|
||||
peak = peak.max(v.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());
|
||||
}
|
||||
if peak > self.true_peak {
|
||||
self.true_peak = peak;
|
||||
@@ -395,63 +381,3 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,24 +54,7 @@ fn max_len(ch: &[Vec<f32>]) -> usize {
|
||||
|
||||
/// The largest finite sample magnitude in any channel.
|
||||
pub fn peak(ch: &[Vec<f32>]) -> f32 {
|
||||
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)
|
||||
ch.iter().flatten().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
/// Peak-normalizes to `target_db` dBFS, either all channels together or each on its own.
|
||||
@@ -127,11 +110,8 @@ pub fn remove_dc(ch: &mut [Vec<f32>]) {
|
||||
if c.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (sum, n) = c.iter().filter(|v| v.is_finite()).fold((0.0f64, 0usize), |(s, n), &v| (s + f64::from(v), n + 1));
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let m = (sum / n as f64) as f32;
|
||||
let mean = c.iter().filter(|v| v.is_finite()).map(|&v| f64::from(v)).sum::<f64>() / c.len() as f64;
|
||||
let m = mean as f32;
|
||||
c.iter_mut().for_each(|s| *s -= m);
|
||||
}
|
||||
}
|
||||
@@ -892,14 +872,4 @@ mod tests {
|
||||
assert_eq!(ch[0][50], 1.0);
|
||||
fade(&mut ch, 1000, 1000, FadeShape::SCurve);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_dc_ignores_non_finite_samples_in_the_mean() {
|
||||
let mut ch = vec![vec![f32::NAN, 1.0, 1.0, 1.0]];
|
||||
remove_dc(&mut ch);
|
||||
assert!(ch[0][1..].iter().all(|x| x.abs() < 1e-6), "left over dc {:?}", ch[0]);
|
||||
let mut all_bad = vec![vec![f32::NAN, f32::INFINITY]];
|
||||
remove_dc(&mut all_bad);
|
||||
assert!(all_bad[0][0].is_nan() && all_bad[0][1].is_infinite());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,10 +502,7 @@ pub struct Maximizer {
|
||||
sr: f32,
|
||||
la: usize,
|
||||
delay: Vec<DelayLine>,
|
||||
win: Vec<(u64, f32)>,
|
||||
win_head: usize,
|
||||
win_len: usize,
|
||||
clock: u64,
|
||||
req: Vec<f32>,
|
||||
smooth: Vec<f32>,
|
||||
idx: usize,
|
||||
sum: f64,
|
||||
@@ -529,10 +526,7 @@ impl Maximizer {
|
||||
sr: 48_000.0,
|
||||
la: 1,
|
||||
delay: Vec::new(),
|
||||
win: vec![(0, 1.0)],
|
||||
win_head: 0,
|
||||
win_len: 0,
|
||||
clock: 0,
|
||||
req: vec![1.0],
|
||||
smooth: vec![1.0],
|
||||
idx: 0,
|
||||
sum: 1.0,
|
||||
@@ -553,40 +547,6 @@ 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 {
|
||||
@@ -602,7 +562,7 @@ impl Plugin for Maximizer {
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
self.win = vec![(0, 1.0); self.la];
|
||||
self.req = vec![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);
|
||||
@@ -612,9 +572,7 @@ impl Plugin for Maximizer {
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.delay.iter_mut().for_each(DelayLine::clear);
|
||||
self.win_head = 0;
|
||||
self.win_len = 0;
|
||||
self.clock = 0;
|
||||
self.req.iter_mut().for_each(|v| *v = 1.0);
|
||||
self.smooth.iter_mut().for_each(|v| *v = 1.0);
|
||||
self.sum = self.la as f64;
|
||||
self.held = 1.0;
|
||||
@@ -640,13 +598,16 @@ impl Plugin for Maximizer {
|
||||
peak = peak.max(x.abs());
|
||||
}
|
||||
let req = if peak > ceil { ceil / peak } else { 1.0 };
|
||||
let hold = self.window_min(req, la);
|
||||
if let Some(r) = self.req.get_mut(self.idx) {
|
||||
*r = req;
|
||||
}
|
||||
let hold = self.req.iter().copied().fold(1.0f32, f32::min);
|
||||
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 = wrap(self.idx + 1, la);
|
||||
self.idx = (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();
|
||||
@@ -671,44 +632,3 @@ 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
-16
@@ -34,8 +34,7 @@ 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 {
|
||||
let a = x.abs();
|
||||
if (a > 1.0e-25) & (a < f32::INFINITY) { x } else { 0.0 }
|
||||
if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Number of frames a plugin may safely touch.
|
||||
@@ -310,17 +309,3 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,94 +298,3 @@ 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?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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", ¶ms) {
|
||||
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");
|
||||
}
|
||||
@@ -255,7 +255,7 @@ impl Autosave {
|
||||
|
||||
/// Delete the folder: the session closed with nothing left to recover.
|
||||
fn discard(mut self) {
|
||||
self.lock = None;
|
||||
drop(self.lock.take());
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,6 @@ 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) => {
|
||||
@@ -311,7 +310,7 @@ pub fn recover(e: &mut Engine, left: &Leftover) -> Result<Vec<String>> {
|
||||
let mut r = Engine::default();
|
||||
let session = left.dir.join(SESSION);
|
||||
let links = left.origin.as_deref().map_or(session.as_path(), Path::new);
|
||||
let mut missing = open_session_linked(&mut r, &session, links)?;
|
||||
let mut missing = open_session_linked(&mut r, &session.to_string_lossy(), 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()) {
|
||||
@@ -536,7 +535,7 @@ mod tests {
|
||||
w.flush().unwrap();
|
||||
let file = std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join(SESSION)).collect::<Vec<_>>();
|
||||
let mut r = Engine::default();
|
||||
open_session(&mut r, &file[0]).unwrap();
|
||||
open_session(&mut r, &file[0].to_string_lossy()).unwrap();
|
||||
assert_eq!((file.len(), r.session().sources.len()), (1, 5));
|
||||
w.close(false);
|
||||
w.flush().unwrap();
|
||||
|
||||
@@ -89,11 +89,7 @@ fn decode_group_audio(
|
||||
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
|
||||
for (old, rel) in audio_entries {
|
||||
let full = dir.join(rel);
|
||||
if !full.starts_with(dir)
|
||||
|| Path::new(rel)
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_)))
|
||||
{
|
||||
if !full.starts_with(dir) || rel.contains("..") {
|
||||
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
}
|
||||
let key = crate::io::file_key(&full);
|
||||
@@ -237,40 +233,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_accepts_audio_names_with_consecutive_dots_but_not_parent_dirs() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-dots-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("drums.scgrp").to_string_lossy().to_string();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap();
|
||||
let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect();
|
||||
e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap();
|
||||
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
for (i, rel) in file["audio"].as_object_mut().unwrap().values_mut().enumerate() {
|
||||
let old = dir.join(rel.as_str().unwrap());
|
||||
let new = format!("Wait... what_{i}.wav");
|
||||
std::fs::rename(&old, dir.join(&new)).unwrap();
|
||||
*rel = json!(new);
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!("../escape.wav");
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "{r:?}");
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!(dir.join("Wait... what_0.wav").to_string_lossy());
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_clips_carry_no_auto_rename_scheme() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
|
||||
|
||||
@@ -28,7 +28,6 @@ mod plugin_state;
|
||||
mod query;
|
||||
pub(crate) mod sampler;
|
||||
mod setup_more;
|
||||
mod strip_silence;
|
||||
mod surround;
|
||||
mod tab;
|
||||
mod track;
|
||||
@@ -64,7 +63,7 @@ mod panic_probe {
|
||||
pub use file::bounce_job;
|
||||
|
||||
pub(crate) use edit::remember_selection;
|
||||
pub use strip_silence::strip_silence_scan;
|
||||
pub use edit::strip_silence_scan;
|
||||
|
||||
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
|
||||
/// `Err(reason)` = disabled.
|
||||
@@ -161,7 +160,6 @@ 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
|
||||
@@ -324,15 +322,11 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Opt
|
||||
Err(bad(cmd, format!("`{key}` must be samples, {{seconds}}, or a time string")))
|
||||
}
|
||||
|
||||
/// Range from `start`/`end`/`length` params. A missing `start` defaults to the edit selection start;
|
||||
/// with neither `end` nor `length` either, the whole edit selection is used.
|
||||
/// Range from `start`/`end` params, else the edit selection.
|
||||
pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result<Range> {
|
||||
let mut st = if p.get("start").is_some_and(Value::is_null) { None } else { position_param(e, cmd, p, "start")? };
|
||||
let st = position_param(e, cmd, p, "start")?;
|
||||
let en = position_param(e, cmd, p, "end")?;
|
||||
let len = position_param(e, cmd, p, "length")?;
|
||||
if st.is_none() && (en.is_some() || len.is_some()) {
|
||||
st = Some(e.session().edit.selection.start);
|
||||
}
|
||||
match (st, en, len) {
|
||||
(Some(a), Some(b), _) => Ok(Range::new(a, b)),
|
||||
(Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))),
|
||||
|
||||
@@ -20,23 +20,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)]
|
||||
}
|
||||
|
||||
/// How many times longer than its input a process's output can be.
|
||||
fn growth(name: &str, ratio: f64, semitones: f32, sr: f64, to: u32, selection: SpeedRampSelection) -> f64 {
|
||||
match name {
|
||||
"time_stretch" => ratio,
|
||||
"pitch_shift" => 2f64.powf(f64::from(semitones) / 12.0),
|
||||
"varispeed" => f64::from(to) / sr,
|
||||
"speed_ramp" if selection == SpeedRampSelection::Extend => 1.5,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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" | "pitch_shift" | "varispeed" | "speed_ramp" => {
|
||||
(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / growth(name, ratio, semitones, sr, to, selection)) as usize
|
||||
}
|
||||
"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,
|
||||
}
|
||||
}
|
||||
@@ -85,32 +76,14 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo };
|
||||
let fades = get("fades", 1.0) >= 0.5;
|
||||
let most = frame_limit(&name, ratio, semitones, sr.as_f64(), to, selection);
|
||||
// Each clip once: a clip listed again would otherwise process the result of its first pass
|
||||
// (an expanding process compounding its length every time).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let ids: Vec<_> = ids.into_iter().filter(|id| seen.insert(*id)).collect();
|
||||
// Refuse before processing anything. Every result is kept as a new source for the life of
|
||||
// the command (and undo holds it), so cap the total output across clips, not just each one.
|
||||
let grows = growth(&name, ratio, semitones, sr.as_f64(), to, selection).max(1.0);
|
||||
let mut budget = soundcraft_mix::MAX_RENDER_SAMPLES;
|
||||
for id in &ids {
|
||||
let Some((track, clip)) = e.session().find_clip(*id) else { continue };
|
||||
if !clip.is_audio() {
|
||||
continue;
|
||||
}
|
||||
let channels = e.session().track(track).map_or(1, |t| t.channels()).max(1);
|
||||
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
|
||||
let frames = (clip.length.max(0) as f64 * grows).ceil() as usize;
|
||||
budget = budget
|
||||
.checked_sub(frames.saturating_mul(channels))
|
||||
.ok_or_else(|| bad("audiosuite.process", "too much audio to process in one command"))?;
|
||||
}
|
||||
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 };
|
||||
if !clip.is_audio() {
|
||||
continue;
|
||||
}
|
||||
let channels = e.session().track(track).map_or(1, |t| t.channels());
|
||||
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
|
||||
let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range());
|
||||
// Render without the clip's own fades: re-render a copy without fades so they stay editable.
|
||||
let mut tmp = e.session().clone();
|
||||
@@ -164,7 +137,6 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
nc.name = new_name;
|
||||
nc.content = ClipContent::Audio { source: src, offset: 0 };
|
||||
nc.length = i64::try_from(frames).unwrap_or(nc.length);
|
||||
nc.clamp_fades();
|
||||
// The render already has the clip's stretch applied; keeping it would stretch the new file again.
|
||||
nc.stretch = 1.0;
|
||||
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
|
||||
@@ -274,21 +246,6 @@ mod tests {
|
||||
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortening_process_clamps_fades_to_the_new_length() {
|
||||
let (mut e, id) = gained_clip();
|
||||
{
|
||||
let c = e.session_mut().find_clip_mut(id).unwrap();
|
||||
c.fade_in.len = 60;
|
||||
c.fade_out.len = 40;
|
||||
}
|
||||
e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 0.25}, "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
assert!(c.length < 64, "{}", c.length);
|
||||
assert!(c.fade_in.len <= c.length, "fade in {} > length {}", c.fade_in.len, c.length);
|
||||
assert_eq!(c.fade_out.len, 40.min(c.length));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processed_clip_does_not_keep_the_stretch_it_baked() {
|
||||
for stretch in [0.5, 2.0] {
|
||||
@@ -377,40 +334,6 @@ mod tests {
|
||||
assert_eq!(e.session().tracks[0].clips().len(), 1, "one clip, not one per duplicate listing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_listed_twice_is_processed_once() {
|
||||
let (mut e, id) = gained_clip();
|
||||
let out =
|
||||
e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 4.0}, "clips": [id.0, id.0, id.0]})).unwrap();
|
||||
assert_eq!(out["processed"], 1);
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
assert!(c.length <= 64 * 4 + 64, "stretched once, not compounded: {}", c.length);
|
||||
assert_eq!(e.session().sources.len(), 2, "one new source, not one per listing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_refuses_a_total_budget_across_clips() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, FileFormat::Wav);
|
||||
// Three clips each within the per-clip limit whose results together exceed the budget.
|
||||
let len = (soundcraft_mix::MAX_RENDER_SAMPLES / 3 + 1) as i64;
|
||||
let ids: Vec<u64> = (0..3)
|
||||
.map(|k| {
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, k * len, len));
|
||||
id.0
|
||||
})
|
||||
.collect();
|
||||
let mut e = crate::Engine::new(s);
|
||||
let before = serde_json::to_value(e.session()).unwrap();
|
||||
let out = e.execute("audiosuite.process", &json!({"process": "reverse", "clips": ids}));
|
||||
let ok = matches!(out, Err(crate::EngineError::BadParams(_, ref m)) if m.contains("too much audio"));
|
||||
assert!(ok, "three clips over budget, but got: {out:?}");
|
||||
assert_eq!(serde_json::to_value(e.session()).unwrap(), before, "refused before processing anything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_refuses_a_total_budget_across_tracks() {
|
||||
let mut e = Engine::new(Session::default());
|
||||
|
||||
@@ -228,7 +228,6 @@ 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;
|
||||
}
|
||||
@@ -247,7 +246,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.clamp_fades();
|
||||
c.fade_out.len = c.fade_out.len.min(c.length);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -497,27 +496,6 @@ 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();
|
||||
|
||||
@@ -196,14 +196,8 @@ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn effects_set(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let params = p.get("params").and_then(Value::as_object).ok_or_else(|| bad("clip.effects_set", "`params` must be an object of numbers"))?;
|
||||
let vals: Vec<(String, f64)> = params
|
||||
.iter()
|
||||
.filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x)))
|
||||
.map(|(k, v)| {
|
||||
let v = if k == "gain" { v.clamp(f64::from(soundcraft_dsp::MIN_DB), f64::from(soundcraft_mix::CLIP_FX_MAX_GAIN_DB)) } else { v };
|
||||
(k, v)
|
||||
})
|
||||
.collect();
|
||||
let vals: Vec<(String, f64)> =
|
||||
params.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))).collect();
|
||||
let s = e.session_mut();
|
||||
for c in &ids {
|
||||
for (k, v) in &vals {
|
||||
@@ -322,13 +316,6 @@ mod tests {
|
||||
(Engine::new(s), t, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_set_caps_gain() {
|
||||
let (mut e, _t, ids) = engine_with(&[(0, 100, 0)]);
|
||||
e.execute("clip.effects_set", &json!({"clip": ids[0].0, "params": {"gain": 500.0}})).unwrap();
|
||||
assert_eq!(e.session().edit.values.get(&format!("clip_fx.{}.gain", ids[0].0)).copied(), Some(24.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloop_removes_copies() {
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100, 100, 0), (200, 100, 0), (300, 100, 50)]);
|
||||
|
||||
@@ -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, 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, f64_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,6 +125,15 @@ 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...",
|
||||
@@ -376,7 +385,13 @@ fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
if range.is_empty() && !ids.is_empty() {
|
||||
n = ops::remove_clips(s, &ids, sh);
|
||||
for t in &mut s.tracks {
|
||||
if let Some(pl) = t.playlist_mut() {
|
||||
let before = pl.clips.len();
|
||||
pl.clips.retain(|c| !ids.contains(&c.id));
|
||||
n += before - pl.clips.len();
|
||||
}
|
||||
}
|
||||
s.edit.selected_clips.clear();
|
||||
} else {
|
||||
for t in &tracks {
|
||||
@@ -806,8 +821,38 @@ 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)) = super::strip_silence::strip_settings(e, "edit.strip_silence", p)?;
|
||||
let (tracks, r, thr, min_len, (pre, post)) = 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}`")))?,
|
||||
|
||||
@@ -115,13 +115,11 @@ fn tempo_constant(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let r = range_param(e, id, p)?;
|
||||
let s = e.session();
|
||||
let (t0, t1) = (s.tempo.samples_to_ticks(r.start, s.sample_rate), s.tempo.samples_to_ticks(r.end, s.sample_rate));
|
||||
if t1 <= t0 {
|
||||
return tempo(e, p);
|
||||
}
|
||||
let bpm = f64_or(p, "bpm", f64::NAN);
|
||||
let s = e.session_mut();
|
||||
if t1 <= t0 {
|
||||
// An empty range sets the tempo at its start, which may come from the selection.
|
||||
s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?;
|
||||
return Ok(json!({"tick": t0, "bpm": bpm}));
|
||||
}
|
||||
// Read before the edit: the end is a musical position, and the new tempo moves it in samples.
|
||||
let after = s.tempo.tempo_at_tick(t1);
|
||||
let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1);
|
||||
@@ -165,13 +163,6 @@ 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()}))
|
||||
@@ -199,16 +190,6 @@ 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()}))
|
||||
@@ -321,10 +302,7 @@ 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),
|
||||
_ => {
|
||||
let point = if e.transport.playing { e.transport.position } else { sel.start };
|
||||
(point, point)
|
||||
}
|
||||
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let n = s.markers.len() + 1;
|
||||
@@ -429,46 +407,6 @@ mod tests {
|
||||
use crate::demo::demo_engine;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tempo_constant_with_an_empty_range_sets_the_tempo_at_its_start() {
|
||||
use soundcraft_time::Range;
|
||||
let mut e = demo_engine();
|
||||
e.session_mut().edit.selection = Range::point(96_000);
|
||||
let r = e.execute("event.tempo_constant", &json!({"bpm": 90.0, "end": 96_000})).unwrap();
|
||||
let s = e.session();
|
||||
let tick = s.tempo.samples_to_ticks(96_000, s.sample_rate);
|
||||
assert_eq!(r["tick"], tick);
|
||||
assert_eq!(s.tempo.tempo_at_tick(tick), 90.0);
|
||||
}
|
||||
|
||||
/// `start` is optional for insert/cut time: it defaults to the selection start, then `end`/`length` apply.
|
||||
#[test]
|
||||
fn insert_and_cut_time_without_start_use_selection_start() {
|
||||
use soundcraft_time::Range;
|
||||
let mut e = demo_engine();
|
||||
let a = e.session().edit.selection.start;
|
||||
let run = |e: &mut crate::Engine, sel: Range, id: &str, params: serde_json::Value| {
|
||||
e.session_mut().edit.selection = sel;
|
||||
e.execute(id, ¶ms).unwrap()
|
||||
};
|
||||
// Point selection.
|
||||
assert_eq!(run(&mut e, Range::point(a), "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
|
||||
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
|
||||
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"length": 700}))["removed"], 700);
|
||||
// A non-empty selection must not override the given `end`/`length`.
|
||||
let sel = Range::new(a, a + 100);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"end": a + 2000}))["inserted"], 2000);
|
||||
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
|
||||
// A null `start` takes the same path.
|
||||
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"start": null, "end": a + 3000}))["removed"], 3000);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": null, "length": 900}))["inserted"], 900);
|
||||
// An explicit `start` still wins over the selection.
|
||||
let r = run(&mut e, sel, "event.cut_time", json!({"start": a + 1000, "end": a + 3000}));
|
||||
assert_eq!(r["removed"], 2000);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": a + 1000, "length": 500}))["inserted"], 500);
|
||||
}
|
||||
|
||||
/// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position.
|
||||
#[test]
|
||||
fn tempo_change_at_position_is_applied() {
|
||||
@@ -535,19 +473,6 @@ 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() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user