mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-29 04:56:46 -06:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6befe57af | ||
|
|
9f2be37e36 | ||
|
|
e82c1689b3 | ||
|
|
40f593a360 | ||
|
|
662cf85c00 | ||
|
|
a8c19f0ffe | ||
|
|
bf9ad66ea6 | ||
|
|
747c79e3a7 | ||
|
|
d293336eac | ||
|
|
7efeed6c9e | ||
|
|
7511548c7e | ||
|
|
1e6310f85b | ||
|
|
240726bf32 | ||
|
|
6686118627 | ||
|
|
5396ecbdbc | ||
|
|
de6108a34d | ||
|
|
450ba857c1 |
@@ -0,0 +1,26 @@
|
||||
# Structural rules for the command-line code (RFC-0012): lint/ast-grep's rule tests, then a scan.
|
||||
# Seconds, no Rust build.
|
||||
name: ast-grep
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["lint/ast-grep/**", "apps/soundcraft-cli/**", "crates/automation/src/args.rs", "apps/soundcraft/src/launch_args.rs", "apps/soundcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
|
||||
pull_request:
|
||||
paths: ["lint/ast-grep/**", "apps/soundcraft-cli/**", "crates/automation/src/args.rs", "apps/soundcraft/src/launch_args.rs", "apps/soundcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ast-grep-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
# Tracked at @main like the other shared workflows.
|
||||
uses: storytold/craft-common-actions/.github/workflows/ast-grep.yml@main
|
||||
with:
|
||||
config: lint/ast-grep/sgconfig.yml
|
||||
paths: apps/soundcraft-cli crates/automation/src/args.rs apps/soundcraft/src/launch_args.rs apps/soundcraft/src/main.rs
|
||||
@@ -21,3 +21,27 @@ jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -26,8 +26,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
with:
|
||||
app-name: soundcraft
|
||||
app-dir: SoundCraft
|
||||
install-relative-path: Programs/ArtCraft/SoundCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -55,3 +55,4 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts
|
||||
| `assets/app-icon/soundcraft.ico` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
|
||||
| `assets/app-icon/hicolor/` | SoundCraft contributors | original, rendered from `icon.svg` (16–512 px PNGs and the scalable SVG) | MIT OR Apache-2.0 |
|
||||
| `docs/images/` | SoundCraft contributors | screenshots of SoundCraft rendered from the synthesised demo session | MIT OR Apache-2.0 |
|
||||
| `crates/audio-io/tests/fixtures/` | SoundCraft contributors | original test tones and silence generated with ffmpeg (sine) or by hand; commands in `crates/audio-io/tests/fixtures/README.md` | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
Read `AGENTS.md`: it is the entry point for agents in this repo.
|
||||
|
||||
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
|
||||
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
|
||||
before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
|
||||
Generated
+410
-38
@@ -200,6 +200,56 @@ 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"
|
||||
@@ -738,12 +788,71 @@ 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"
|
||||
@@ -753,6 +862,15 @@ 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"
|
||||
@@ -773,6 +891,12 @@ 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"
|
||||
@@ -934,12 +1058,40 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -947,20 +1099,28 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
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=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1460,6 +1620,17 @@ 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"
|
||||
@@ -1857,6 +2028,12 @@ 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"
|
||||
@@ -1887,9 +2064,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
@@ -1900,43 +2077,110 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.4.0"
|
||||
name = "icu_decimal"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
|
||||
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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.4.0"
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
|
||||
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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
|
||||
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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
@@ -1949,18 +2193,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.4.0"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"postcard",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -1968,6 +2215,30 @@ 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"
|
||||
@@ -2034,6 +2305,12 @@ 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"
|
||||
@@ -2259,6 +2536,9 @@ name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -2906,6 +3186,12 @@ 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"
|
||||
@@ -3188,12 +3474,24 @@ 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",
|
||||
]
|
||||
|
||||
@@ -3518,6 +3816,12 @@ 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"
|
||||
@@ -3975,9 +4279,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"craft-update",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3987,6 +4292,8 @@ dependencies = [
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-automation",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-playback",
|
||||
"soundcraft-ui-egui",
|
||||
@@ -3996,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4006,7 +4313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4018,17 +4325,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-time",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4041,18 +4351,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
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.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4063,7 +4379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4086,7 +4402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4096,7 +4412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4112,7 +4428,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4125,7 +4441,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4140,7 +4456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4150,7 +4466,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -4179,7 +4495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4191,7 +4507,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4207,7 +4523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4215,6 +4531,7 @@ dependencies = [
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-model",
|
||||
"soundcraft-playback",
|
||||
@@ -4251,6 +4568,12 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[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"
|
||||
@@ -4487,6 +4810,18 @@ 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"
|
||||
@@ -4610,6 +4945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4847,6 +5183,12 @@ 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"
|
||||
@@ -4859,6 +5201,12 @@ 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"
|
||||
@@ -5473,6 +5821,17 @@ 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"
|
||||
@@ -5700,11 +6059,20 @@ 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"
|
||||
@@ -5771,7 +6139,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -5959,8 +6327,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5969,6 +6340,7 @@ version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+11
-6
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -29,30 +29,35 @@ soundcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
clap_complete = "4.6.11"
|
||||
clap_mangen = { version = "0.3.3", features = ["env"] }
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
# Whether an output path is one of the inputs (the CLI refuses to replace an input unless asked).
|
||||
same-file = "1"
|
||||
proptest = "1"
|
||||
|
||||
# audio
|
||||
symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["mp1", "mp2", "mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
|
||||
rustfft = "6"
|
||||
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
|
||||
zip = { version = "9", default-features = false }
|
||||
cpal = "0.18"
|
||||
|
||||
# Which file paths stored in a session may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b" }
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
egui = { version = "0.37" }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
|
||||
|
||||
@@ -184,7 +184,7 @@ error only.
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for SoundCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,soundcraft_mix=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`soundcraft*=debug`). |
|
||||
| `SOUNDCRAFT_NO_PREFS` | No preferences read or written and no log file (agents' test runs). |
|
||||
| `SOUNDCRAFT_NO_PREFS` | `1`: no preferences read or written and no log file (agents' test runs). |
|
||||
|
||||
The realtime audio thread never writes a record itself: the logger keeps the first one it logs
|
||||
(without waiting or allocating), counts the rest, and the UI thread writes them. The logger is
|
||||
@@ -216,8 +216,9 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo
|
||||
soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
|
||||
```
|
||||
|
||||
See [`docs/control-protocol.md`](docs/control-protocol.md) and [`docs/mcp.md`](docs/mcp.md).
|
||||
`soundcraft-cli commands` lists every command.
|
||||
See [`docs/cli.md`](docs/cli.md) for the command-line tool (subcommands, exit codes, environment
|
||||
variables, man pages and completions), [`docs/control-protocol.md`](docs/control-protocol.md) and
|
||||
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command.
|
||||
|
||||
Open **SoundCraft › Session Audio Health** to check loaded audio availability, sample-rate
|
||||
mismatches and clip source bounds across all playlists, including alternate takes. The report
|
||||
|
||||
@@ -6,6 +6,10 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "soundcraft_cli"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "soundcraft-cli"
|
||||
path = "src/main.rs"
|
||||
@@ -18,4 +22,12 @@ soundcraft-engine.workspace = true
|
||||
soundcraft-automation.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
clap.workspace = true
|
||||
same-file.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
clap_complete.workspace = true
|
||||
clap_mangen.workspace = true
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Writes soundcraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish,
|
||||
//! PowerShell) and man pages ([`clap_mangen`]: `soundcraft-cli.1` and one per subcommand, such as
|
||||
//! `soundcraft-cli-convert.1`) into a directory. The Linux and FreeBSD packages ship them
|
||||
//! (`packaging/linux/package.sh`, `packaging/freebsd/package.sh`):
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p soundcraft-cli --example man-and-completions -- <out-dir>
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::ValueEnum;
|
||||
use clap_complete::Shell;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let (Some(dir), None) = (args.next(), args.next()) else {
|
||||
eprintln!("usage: man-and-completions <out-dir>");
|
||||
return ExitCode::from(2);
|
||||
};
|
||||
let dir = Path::new(&dir);
|
||||
match generate(dir) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}: {e}", dir.display());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Also run by `tests/it/man_and_completions.rs`.
|
||||
pub(crate) fn generate(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut cmd = soundcraft_cli::command();
|
||||
for &shell in Shell::value_variants() {
|
||||
clap_complete::generate_to(shell, &mut cmd, "soundcraft-cli", dir)?;
|
||||
}
|
||||
// A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand,
|
||||
// which `clap_mangen` would otherwise give pages of its own (`soundcraft-cli-help-convert.1`).
|
||||
clap_mangen::generate_to(soundcraft_cli::command(), dir)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use super::args::AppArgs;
|
||||
use super::error::CliError;
|
||||
use super::output::print_json;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_automation::Remote;
|
||||
use std::io::Write;
|
||||
|
||||
/// Control-channel methods. Every other dotted id is an engine command.
|
||||
fn is_control_method(method: &str) -> bool {
|
||||
matches!(
|
||||
method,
|
||||
"engine.execute"
|
||||
| "command"
|
||||
| "engine.commands"
|
||||
| "engine.parity"
|
||||
| "session.inspect"
|
||||
| "document.inspect"
|
||||
| "ui.inspect"
|
||||
| "ui.menu.list"
|
||||
| "ui.menu.invoke"
|
||||
| "ui.set"
|
||||
| "ui.move"
|
||||
| "ui.click"
|
||||
| "ui.drag"
|
||||
| "ui.key"
|
||||
| "ui.text"
|
||||
| "ui.screenshot"
|
||||
| "app.quit"
|
||||
)
|
||||
}
|
||||
|
||||
/// A command id is shorthand for `engine.execute`. Documented control methods stay methods.
|
||||
fn app_request(method: &str, params: Value) -> (String, Value) {
|
||||
if is_control_method(method) || !method.contains('.') {
|
||||
(method.to_string(), params)
|
||||
} else {
|
||||
("engine.execute".to_string(), json!({"command": method, "params": params}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn app(args: &AppArgs, out: &mut dyn Write) -> Result<(), CliError> {
|
||||
let method = &args.method;
|
||||
let params = args.json.clone().unwrap_or_else(|| json!({}));
|
||||
let mut r = Remote::new(&args.control.port);
|
||||
// A bare command id is shorthand for engine.execute. Control-channel methods stay methods.
|
||||
let (m, p) = app_request(method, params);
|
||||
let v = r.request(&m, p)?;
|
||||
print_json(out, &v, args.compact);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() {
|
||||
let (m, p) = app_request("engine.history", json!({}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
assert_eq!(p["command"], "engine.history");
|
||||
let (m, p) = app_request("ui.theme", json!({"mode": "light"}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
assert_eq!(p["params"]["mode"], "light");
|
||||
let (m, _) = app_request("session.new", json!({"name": "Session A"}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
let (m, _) = app_request("track.new", json!({}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] {
|
||||
assert_eq!(app_request(method, json!({})).0, method);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
//! The command line, as clap types. A flag a subcommand doesn't take is a usage error (exit 2), so
|
||||
//! a typo such as `--bounces` can't be silently ignored (#549), and so is a value flag without its
|
||||
//! value: a following flag is never taken as the value.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::ffi::OsString;
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use clap::builder::NonEmptyStringValueParser;
|
||||
use clap::error::ErrorKind;
|
||||
use clap::{ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
use soundcraft_automation::ControlTarget;
|
||||
use soundcraft_automation::args::{AutomationArgs, ControlClientArgs, blank_env_is_unset, control_target, long_version, name_env_vars};
|
||||
|
||||
/// Help text that names the extensions `list` gives, built once from
|
||||
/// [`FileFormat::readable_extensions`] or [`FileFormat::writable_extensions`] so it can't drift
|
||||
/// from what the code accepts.
|
||||
fn with_formats(cell: &'static OnceLock<String>, before: &str, list: fn() -> Vec<&'static str>, after: &str) -> &'static str {
|
||||
cell.get_or_init(|| format!("{before}({}){after}", list().join(", ")))
|
||||
}
|
||||
|
||||
fn info_file_help() -> &'static str {
|
||||
static C: OnceLock<String> = OnceLock::new();
|
||||
with_formats(&C, "An audio file ", FileFormat::readable_extensions, " or a .scraft session")
|
||||
}
|
||||
|
||||
fn read_help() -> &'static str {
|
||||
static C: OnceLock<String> = OnceLock::new();
|
||||
with_formats(&C, "The audio file to read ", FileFormat::readable_extensions, "")
|
||||
}
|
||||
|
||||
fn write_help() -> &'static str {
|
||||
static C: OnceLock<String> = OnceLock::new();
|
||||
with_formats(&C, "The audio file to write; its extension picks the format ", FileFormat::writable_extensions, "")
|
||||
}
|
||||
|
||||
/// soundcraft-cli: SoundCraft without a window
|
||||
#[derive(Parser)]
|
||||
#[command(name = "soundcraft-cli", version = long_version(), arg_required_else_help = true)]
|
||||
pub(crate) struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum Command {
|
||||
Info(InfoArgs),
|
||||
Convert(ConvertArgs),
|
||||
Run(RunArgs),
|
||||
Script(ScriptArgs),
|
||||
Commands(CommandsArgs),
|
||||
Describe(DescribeArgs),
|
||||
App(AppArgs),
|
||||
Mcp(McpArgs),
|
||||
Parity(ParityArgs),
|
||||
/// List the built-in plugins
|
||||
Plugins,
|
||||
}
|
||||
|
||||
/// Print an audio file's format as JSON, or a .scraft session's summary
|
||||
#[derive(Args)]
|
||||
pub(crate) struct InfoArgs {
|
||||
#[arg(help = info_file_help())]
|
||||
pub(crate) file: PathBuf,
|
||||
/// One line of JSON instead of pretty-printed (a .scraft summary is text and ignores it)
|
||||
#[arg(long)]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Convert an audio file: resample, change the bit depth, normalize
|
||||
///
|
||||
/// Dithers only when the output loses resolution, unless --dither or --no-dither says otherwise.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ConvertArgs {
|
||||
#[arg(value_name = "IN", help = read_help())]
|
||||
pub(crate) input: PathBuf,
|
||||
#[arg(value_name = "OUT", help = write_help())]
|
||||
pub(crate) output: PathBuf,
|
||||
/// The output's bit depth
|
||||
#[arg(long, value_enum, value_name = "16|24|32f", default_value = "24")]
|
||||
pub(crate) bit_depth: BitDepthArg,
|
||||
/// Resample to this sample rate
|
||||
#[arg(long, value_name = "HZ")]
|
||||
pub(crate) rate: Option<NonZeroU32>,
|
||||
/// Normalize the peak to -0.1 dBFS
|
||||
#[arg(long)]
|
||||
pub(crate) normalize: bool,
|
||||
/// Always dither
|
||||
#[arg(long, conflicts_with = "no_dither")]
|
||||
pub(crate) dither: bool,
|
||||
/// Never dither
|
||||
#[arg(long)]
|
||||
pub(crate) no_dither: bool,
|
||||
/// Allow an <OUT> that is the <IN> file, replacing the original
|
||||
#[arg(long)]
|
||||
pub(crate) in_place: bool,
|
||||
}
|
||||
|
||||
/// Where `run` and `script` start, and what they do with the result.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct EngineArgs {
|
||||
/// Open this session first
|
||||
#[arg(long = "in", value_name = "S.scraft", conflicts_with = "demo")]
|
||||
pub(crate) input: Option<PathBuf>,
|
||||
/// Start from the demo session
|
||||
#[arg(long, visible_alias = "sample")]
|
||||
pub(crate) demo: bool,
|
||||
/// Bounce the mix to this file at the end; without --start and --end, the whole session
|
||||
#[arg(long, value_name = "OUT")]
|
||||
pub(crate) bounce: Option<PathBuf>,
|
||||
/// Where the bounce starts: seconds (12.5) or a time string
|
||||
#[arg(long, value_name = "T", allow_negative_numbers = true, requires = "bounce", value_parser = time_arg)]
|
||||
pub(crate) start: Option<TimeArg>,
|
||||
/// Where the bounce ends: seconds (12.5) or a time string
|
||||
#[arg(long, value_name = "T", allow_negative_numbers = true, requires = "bounce", value_parser = time_arg)]
|
||||
pub(crate) end: Option<TimeArg>,
|
||||
/// Save the session to this file at the end, replacing it
|
||||
#[arg(long, value_name = "S.scraft")]
|
||||
pub(crate) save: Option<PathBuf>,
|
||||
/// Print the session as JSON at the end
|
||||
#[arg(long)]
|
||||
pub(crate) inspect: bool,
|
||||
/// Print the --inspect JSON on one line instead of pretty-printed
|
||||
#[arg(long, requires = "inspect")]
|
||||
pub(crate) compact: bool,
|
||||
/// Allow a --save that is the --in session, replacing the original
|
||||
#[arg(long, requires = "input")]
|
||||
pub(crate) in_place: bool,
|
||||
}
|
||||
|
||||
/// Run engine commands on a new, demo or saved session
|
||||
///
|
||||
/// Prints each command's JSON result on one line, then bounces, saves and inspects, in that order.
|
||||
/// The --inspect JSON is pretty-printed unless --compact is given.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct RunArgs {
|
||||
/// An engine command, with its JSON parameters after `=` (default {}); repeat for several, run
|
||||
/// in order
|
||||
#[arg(long, value_name = "ID[=JSON]", action = ArgAction::Append, value_parser = cmd_spec)]
|
||||
pub(crate) cmd: Vec<CmdSpec>,
|
||||
#[command(flatten)]
|
||||
pub(crate) engine: EngineArgs,
|
||||
}
|
||||
|
||||
/// Run a file of engine commands, one `ID JSON` per line
|
||||
///
|
||||
/// Blank lines and lines starting with # are skipped. A failing line stops the script unless
|
||||
/// --keep-going is given.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ScriptArgs {
|
||||
/// The script to run
|
||||
pub(crate) file: PathBuf,
|
||||
/// Report a failing line and go on with the next
|
||||
#[arg(long)]
|
||||
pub(crate) keep_going: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) engine: EngineArgs,
|
||||
}
|
||||
|
||||
/// List the engine commands
|
||||
#[derive(Args)]
|
||||
pub(crate) struct CommandsArgs {
|
||||
/// Only the commands whose id, or label in any case, contains this text
|
||||
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
||||
pub(crate) filter: Option<String>,
|
||||
}
|
||||
|
||||
/// Print one engine command's details as JSON
|
||||
#[derive(Args)]
|
||||
pub(crate) struct DescribeArgs {
|
||||
/// The command's id, such as file.bounce_mix
|
||||
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
||||
pub(crate) id: String,
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long)]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Call a running app's control channel
|
||||
///
|
||||
/// A dotted engine command id is shorthand for engine.execute; the control methods
|
||||
/// (session.inspect, ui.screenshot, …) stay methods. Prints the reply as JSON.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct AppArgs {
|
||||
#[command(flatten)]
|
||||
pub(crate) control: ControlClientArgs,
|
||||
/// A control method or engine command id
|
||||
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
||||
pub(crate) method: String,
|
||||
/// Its parameters as JSON [default: {}]
|
||||
#[arg(value_parser = json_value)]
|
||||
pub(crate) json: Option<Value>,
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long)]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Serve MCP on stdin and stdout
|
||||
///
|
||||
/// The automation roots confine every file the agent's commands read (open, import, presets) and
|
||||
/// write (save, bounce, export) to those folders, links followed. Each root flag may be given more
|
||||
/// than once, a path inside any of its folders being allowed, and a kind left out grants none of
|
||||
/// its access. The roots confine the in-process server, so they, like --demo, conflict with --connect.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct McpArgs {
|
||||
/// Serve a running app's control channel at this port or host:port instead of an in-process
|
||||
/// engine; conflicts with --demo, which starts only that engine
|
||||
#[arg(long, value_name = "PORT", value_parser = control_target, conflicts_with_all = ["demo", "automation_read_root", "automation_write_root"])]
|
||||
pub(crate) connect: Option<ControlTarget>,
|
||||
/// Start the in-process engine from the demo session
|
||||
#[arg(long)]
|
||||
pub(crate) demo: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) automation: AutomationArgs,
|
||||
}
|
||||
|
||||
/// Print the parity report (markdown)
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ParityArgs {
|
||||
/// Write the report to this file instead
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub(crate) write: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// The outputs clap can't check: one that is an input file, however it is spelt, or both of
|
||||
/// `--bounce` and `--save` naming one file. The paths are compared before any work starts and
|
||||
/// written later, so a link swapped in meanwhile goes unnoticed: this guards against mistakes,
|
||||
/// it is not a security boundary.
|
||||
fn check_outputs(&self) -> Result<(), OutputError> {
|
||||
match self {
|
||||
Self::Convert(a) if !a.in_place && same_file(&a.input, &a.output) => {
|
||||
Err(OutputError::ReplacesInput { output: "<OUT>", input: "<IN> file", path: a.output.clone() })
|
||||
}
|
||||
Self::Run(a) => a.engine.check_outputs(None),
|
||||
Self::Script(a) => a.engine.check_outputs(Some(&a.file)),
|
||||
Self::Info(_)
|
||||
| Self::Convert(_)
|
||||
| Self::Commands(_)
|
||||
| Self::Describe(_)
|
||||
| Self::App(_)
|
||||
| Self::Mcp(_)
|
||||
| Self::Parity(_)
|
||||
| Self::Plugins => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConvertArgs {
|
||||
/// The file `convert` writes: <OUT>, or the file it links to when it replaces <IN> in place.
|
||||
pub(crate) fn write_path(&self) -> Cow<'_, Path> {
|
||||
in_place_target(self.in_place, Some(&self.input), &self.output)
|
||||
}
|
||||
}
|
||||
|
||||
impl EngineArgs {
|
||||
/// The file --save writes: its path, or the file it links to when it replaces --in in place.
|
||||
pub(crate) fn save_path(&self) -> Option<Cow<'_, Path>> {
|
||||
self.save.as_deref().map(|save| in_place_target(self.in_place, self.input.as_deref(), save))
|
||||
}
|
||||
|
||||
/// `script` is the script file `script` runs, which no flag lets an output replace. --in-place
|
||||
/// lets only --save replace the --in session: a bounce would turn it into audio.
|
||||
fn check_outputs(&self, script: Option<&Path>) -> Result<(), OutputError> {
|
||||
for (flag, path) in [("--bounce", &self.bounce), ("--save", &self.save)] {
|
||||
let Some(path) = path else { continue };
|
||||
if script.is_some_and(|script| same_file(script, path)) {
|
||||
return Err(OutputError::ReplacesScript { output: flag, path: path.clone() });
|
||||
}
|
||||
}
|
||||
let replaces_input = |path: &Path| self.input.as_ref().is_some_and(|input| same_file(input, path));
|
||||
if let Some(bounce) = &self.bounce
|
||||
&& replaces_input(bounce)
|
||||
{
|
||||
return Err(OutputError::BounceReplacesInput(bounce.clone()));
|
||||
}
|
||||
if let Some(save) = &self.save
|
||||
&& !self.in_place
|
||||
&& replaces_input(save)
|
||||
{
|
||||
return Err(OutputError::ReplacesInput { output: "--save", input: "--in session", path: save.clone() });
|
||||
}
|
||||
match (&self.bounce, &self.save) {
|
||||
(Some(bounce), Some(save)) if same_file(bounce, save) => Err(OutputError::SameOutputs(save.clone())),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` name one file, however spelt (`a.wav`, `./a.wav`, a symbolic or hard link).
|
||||
/// When either exists, only two regular files can be one, compared by identity (device and inode);
|
||||
/// a FIFO or device is never opened, so the check can't block. When neither exists yet, they are
|
||||
/// one if they would be created at the same place.
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
if a.exists() || b.exists() {
|
||||
return a.is_file() && b.is_file() && same_file::is_same_file(a, b).unwrap_or(false);
|
||||
}
|
||||
matches!((destination(a), destination(b)), (Some(a), Some(b)) if a == b)
|
||||
}
|
||||
|
||||
/// With `in_place`, an `output` that is a symbolic link to `input` resolved to the file it names.
|
||||
/// A save writes a new file and renames it over its path, which would replace the link and leave
|
||||
/// the input as it was: through the resolved path, `convert` and `--save` both replace the input.
|
||||
fn in_place_target<'a>(in_place: bool, input: Option<&Path>, output: &'a Path) -> Cow<'a, Path> {
|
||||
let link = output.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink());
|
||||
if in_place
|
||||
&& link
|
||||
&& input.is_some_and(|input| same_file(input, output))
|
||||
&& let Ok(target) = output.canonicalize()
|
||||
{
|
||||
return Cow::Owned(target);
|
||||
}
|
||||
Cow::Borrowed(output)
|
||||
}
|
||||
|
||||
/// Where a file that doesn't exist yet would be created: its folder resolved, links followed.
|
||||
fn destination(path: &Path) -> Option<PathBuf> {
|
||||
let folder = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
Some(folder.canonicalize().ok()?.join(path.file_name()?))
|
||||
}
|
||||
|
||||
/// An output [`Command::check_outputs`] refuses: `parse` prints it as a usage error (exit 2).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum OutputError {
|
||||
#[error("{output} {} is the {input}, which it would replace: choose another file, or pass --in-place", .path.display())]
|
||||
ReplacesInput { output: &'static str, input: &'static str, path: PathBuf },
|
||||
#[error("--bounce {} is the --in session, which it would replace with audio: choose another file", .0.display())]
|
||||
BounceReplacesInput(PathBuf),
|
||||
#[error("{output} {} is the script file, which it would replace: choose another file", .path.display())]
|
||||
ReplacesScript { output: &'static str, path: PathBuf },
|
||||
#[error("--bounce and --save both name {}, so the save would replace the bounce: choose another file for one of them", .0.display())]
|
||||
SameOutputs(PathBuf),
|
||||
}
|
||||
|
||||
impl OutputError {
|
||||
/// Two arguments that may not name one file: clap's conflict kind.
|
||||
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
|
||||
cmd.error(ErrorKind::ArgumentConflict, self)
|
||||
}
|
||||
}
|
||||
|
||||
/// The bit depths `convert` writes. `32` is the same as `32f`.
|
||||
#[derive(Clone, Copy, ValueEnum)]
|
||||
pub(crate) enum BitDepthArg {
|
||||
#[value(name = "16")]
|
||||
Int16,
|
||||
#[value(name = "24")]
|
||||
Int24,
|
||||
#[value(name = "32f", alias = "32")]
|
||||
Float32,
|
||||
}
|
||||
|
||||
impl From<BitDepthArg> for soundcraft_audio_io::BitDepth {
|
||||
fn from(depth: BitDepthArg) -> Self {
|
||||
match depth {
|
||||
BitDepthArg::Int16 => Self::Int16,
|
||||
BitDepthArg::Int24 => Self::Int24,
|
||||
BitDepthArg::Float32 => Self::Float32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounce position: seconds, or a time string the engine reads.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum TimeArg {
|
||||
Seconds(f64),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
impl TimeArg {
|
||||
/// The `start` / `end` parameter of `file.bounce_mix`.
|
||||
pub(crate) fn to_json(&self) -> Value {
|
||||
match self {
|
||||
Self::Seconds(s) => json!({"seconds": s}),
|
||||
Self::Text(t) => json!(t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `--cmd ID[=JSON]`: the command id and its parameters (`{}` when none are given).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct CmdSpec {
|
||||
pub(crate) id: String,
|
||||
pub(crate) params: Value,
|
||||
}
|
||||
|
||||
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ValueError {
|
||||
#[error("expected seconds (12.5) or a time string, not an empty value")]
|
||||
EmptyTime,
|
||||
#[error("expected a finite number of seconds")]
|
||||
NotFinite,
|
||||
#[error("expected a command id, optionally followed by `=` and JSON parameters")]
|
||||
EmptyCommandId,
|
||||
#[error("bad JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
fn time_arg(s: &str) -> Result<TimeArg, ValueError> {
|
||||
if s.is_empty() {
|
||||
return Err(ValueError::EmptyTime);
|
||||
}
|
||||
match s.parse::<f64>() {
|
||||
// JSON has no NaN or infinity: serde_json would send the engine `null`.
|
||||
Ok(secs) if !secs.is_finite() => Err(ValueError::NotFinite),
|
||||
Ok(secs) => Ok(TimeArg::Seconds(secs)),
|
||||
Err(_) => Ok(TimeArg::Text(s.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value(s: &str) -> Result<Value, ValueError> {
|
||||
Ok(serde_json::from_str(s)?)
|
||||
}
|
||||
|
||||
/// `ID` or `ID=JSON`; malformed JSON is an error, not `{}`.
|
||||
fn cmd_spec(s: &str) -> Result<CmdSpec, ValueError> {
|
||||
let (id, params) = match s.split_once('=') {
|
||||
Some((id, p)) => (id, json_value(p)?),
|
||||
None => (s, json!({})),
|
||||
};
|
||||
if id.is_empty() {
|
||||
return Err(ValueError::EmptyCommandId);
|
||||
}
|
||||
Ok(CmdSpec { id: id.to_owned(), params })
|
||||
}
|
||||
|
||||
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a folder need
|
||||
/// not be valid Unicode, text must. A flag wins over its environment variable, unless that is
|
||||
/// empty, and a bad value from one is a usage error naming the variable. A bare `--help` or `-h`
|
||||
/// is help even after a mistake on the same line (`run --bounce --help`): clap reports the mistake
|
||||
/// first. An output that would replace an input is refused here too
|
||||
/// ([`Command::check_outputs`]), so nothing has run when it is reported.
|
||||
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
|
||||
let mut cmd = Cli::command().mut_subcommands(|sub| sub.mut_args(blank_env_is_unset));
|
||||
let argv = std::iter::once(OsString::from("soundcraft-cli")).chain(args.iter().cloned());
|
||||
let matches = match cmd.try_get_matches_from_mut(argv) {
|
||||
Err(e) if e.kind() != ErrorKind::DisplayHelp => {
|
||||
let Some(flag) = args.iter().take_while(|a| *a != "--").find(|a| *a == "--help" || *a == "-h") else {
|
||||
return Err(name_env_vars(e, &cmd, args));
|
||||
};
|
||||
let help = [OsString::from("soundcraft-cli"), args.first().cloned().unwrap_or_default(), flag.clone()];
|
||||
return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e));
|
||||
}
|
||||
matches => matches?,
|
||||
};
|
||||
let cli = Cli::from_arg_matches(&matches)?;
|
||||
if let Err(e) = cli.command.check_outputs() {
|
||||
return Err(match matches.subcommand_name().and_then(|name| cmd.find_subcommand_mut(name)) {
|
||||
Some(sub) => e.into_clap(sub),
|
||||
None => e.into_clap(&mut cmd),
|
||||
});
|
||||
}
|
||||
Ok(cli)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
|
||||
//! flag must not silently remove a root and leave the server unconfined.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AutomationArgs {
|
||||
pub read_roots: Vec<PathBuf>,
|
||||
pub write_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
/// Returns whether this argument is a root flag. Each flag may be given more than once (#874),
|
||||
/// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => &mut self.read_roots,
|
||||
"--automation-write-root" => &mut self.write_roots,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
roots.push(value.into());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_add_up_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::default();
|
||||
let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--demo", &mut args).unwrap());
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
|
||||
assert_eq!(args.next().as_deref(), Some("song.scraft"));
|
||||
// #874: a flag given again adds a folder, in either spelling.
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_leave_roots_unchanged() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs { read_roots: vec!["read".into()], write_roots: vec!["write".into()] };
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
|
||||
}
|
||||
let mut roots = AutomationArgs::default();
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::default();
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use super::args::{CommandsArgs, DescribeArgs, ParityArgs};
|
||||
use super::error::CliError;
|
||||
use super::output::print_json;
|
||||
use soundcraft_engine::Engine;
|
||||
use std::io::Write;
|
||||
|
||||
pub(crate) fn commands(args: &CommandsArgs, out: &mut dyn Write) {
|
||||
let filter = args.filter.clone().unwrap_or_default();
|
||||
let e = Engine::default();
|
||||
for c in soundcraft_engine::command_specs() {
|
||||
if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
let i = c.info(&e);
|
||||
outln!(out, "{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn describe(args: &DescribeArgs, out: &mut dyn Write) -> Result<(), CliError> {
|
||||
let id = &args.id;
|
||||
let c = soundcraft_engine::find_command(id).ok_or_else(|| CliError::UnknownCommand(id.clone()))?;
|
||||
print_json(out, &c.info(&Engine::default()), args.compact);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parity(args: &ParityArgs, out: &mut dyn Write) -> Result<(), CliError> {
|
||||
let p = soundcraft_engine::catalog::parity_json();
|
||||
let mut md = String::new();
|
||||
md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`).\n");
|
||||
md.push_str("It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.\n");
|
||||
md.push_str("UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).\n\n");
|
||||
md.push_str(&format!(
|
||||
"**Menu items implemented by the engine: {} / {} ({}%)**\n\n| Menu | Implemented | Total |\n|---|---:|---:|\n",
|
||||
p["implemented"], p["total"], p["percent"]
|
||||
));
|
||||
for m in p["per_menu"].as_array().cloned().unwrap_or_default() {
|
||||
md.push_str(&format!("| {} | {} | {} |\n", m["menu"].as_str().unwrap_or(""), m["implemented"], m["total"]));
|
||||
}
|
||||
md.push_str("\n## Not yet implemented\n\n");
|
||||
for m in p["missing"].as_array().cloned().unwrap_or_default() {
|
||||
md.push_str(&format!("- {}\n", m.as_str().unwrap_or("")));
|
||||
}
|
||||
if let Some(path) = &args.write {
|
||||
std::fs::write(path, &md).map_err(|source| CliError::File { path: path.clone(), source })?;
|
||||
outln!(out, "wrote {}: {}% ({}/{})", path.display(), p["percent"], p["implemented"], p["total"]);
|
||||
} else {
|
||||
outln!(out, "{md}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use super::args::ConvertArgs;
|
||||
use super::error::{CliError, ConvertError};
|
||||
use std::io::Write;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), CliError> {
|
||||
let (input, output) = (&args.input, &args.output);
|
||||
let rate = args.rate.map(NonZeroU32::get);
|
||||
let bit_depth = args.bit_depth.into();
|
||||
let dither_flag = match (args.dither, args.no_dither) {
|
||||
(true, _) => Some(true),
|
||||
(_, true) => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
let bytes = std::fs::read(input).map_err(|source| CliError::File { path: input.clone(), source })?;
|
||||
let ext = input.extension().and_then(std::ffi::OsStr::to_str);
|
||||
let (info, mut buf) = soundcraft_audio_io::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?;
|
||||
if let Some(rate) = rate {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
|
||||
buf.sample_rate = rate;
|
||||
}
|
||||
let normalize = args.normalize;
|
||||
if normalize {
|
||||
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
|
||||
}
|
||||
let processed = rate.is_some_and(|r| r != info.sample_rate) || normalize;
|
||||
let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed));
|
||||
// Lossy only for the message: an extension that is not valid Unicode names no format.
|
||||
let out_ext = output.extension().map_or_else(|| "wav".into(), |e| e.to_string_lossy().to_ascii_lowercase());
|
||||
let Some(format) = soundcraft_audio_io::FileFormat::from_write_extension(&out_ext) else {
|
||||
return Err(ConvertError::OutputFormat { output: output.clone(), ext: out_ext }.into());
|
||||
};
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?;
|
||||
write_replacing(&args.write_path(), &bytes).map_err(|source| CliError::File { path: output.clone(), source })?;
|
||||
outln!(out, "wrote {} ({} ch, {} Hz, {:.2} s)", output.display(), buf.num_channels(), buf.sample_rate, buf.duration_secs());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes `bytes` to a new file next to `target`, flushes it and renames it over `target`, so `target`
|
||||
/// (the input, for --in-place) holds either its old or its new contents. The new file is removed when
|
||||
/// writing or renaming fails.
|
||||
fn write_replacing(target: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let name = target.file_name().map_or_else(|| "output".into(), |n| n.to_string_lossy().into_owned());
|
||||
let tmp = target.with_file_name(format!(".{name}.{}.part", std::process::id()));
|
||||
let written = (|| {
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp, target)
|
||||
})();
|
||||
if written.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// Whether `convert` dithers when not told: only when writing integer samples that cannot hold the
|
||||
/// source exactly, i.e. the output has fewer bits than the source, the source is float or lossy, or
|
||||
/// resampling/normalizing produced new sample values. A 16-bit file written as 16 or 24-bit, or
|
||||
/// anything written as 32-bit float, is copied sample for sample (digital silence stays silent).
|
||||
fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_audio_io::BitDepth, processed: bool) -> bool {
|
||||
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
||||
let target_bits = match target {
|
||||
BitDepth::Float32 => return false,
|
||||
BitDepth::Int16 => 16,
|
||||
BitDepth::Int24 => 24,
|
||||
BitDepth::Int32 => 32,
|
||||
};
|
||||
if processed {
|
||||
return true;
|
||||
}
|
||||
match source {
|
||||
SampleFormat::Int8 => target_bits < 8,
|
||||
SampleFormat::Int16 => target_bits < 16,
|
||||
SampleFormat::Int24 => target_bits < 24,
|
||||
// Decoded through f32, which holds 24 bits: a 32-bit integer source has already lost
|
||||
// resolution, so treat it like float.
|
||||
SampleFormat::Int32 | SampleFormat::Float32 | SampleFormat::Float64 | SampleFormat::Compressed => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn convert_dithers_only_when_resolution_is_lost() {
|
||||
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
||||
// Same depth or wider: the samples fit exactly.
|
||||
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int16, false));
|
||||
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int24, false));
|
||||
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Int24, false));
|
||||
assert!(!auto_dither(SampleFormat::Int8, BitDepth::Int16, false));
|
||||
// Float output never dithers.
|
||||
assert!(!auto_dither(SampleFormat::Float32, BitDepth::Float32, true));
|
||||
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Float32, false));
|
||||
// Fewer bits, a float or lossy source, or processed samples: dither.
|
||||
assert!(auto_dither(SampleFormat::Int24, BitDepth::Int16, false));
|
||||
assert!(auto_dither(SampleFormat::Float32, BitDepth::Int24, false));
|
||||
assert!(auto_dither(SampleFormat::Compressed, BitDepth::Int16, false));
|
||||
assert!(auto_dither(SampleFormat::Int32, BitDepth::Int24, false));
|
||||
assert!(auto_dither(SampleFormat::Int16, BitDepth::Int16, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use soundcraft_audio_io::AudioError;
|
||||
use soundcraft_automation::RemoteError;
|
||||
use soundcraft_engine::EngineError;
|
||||
use soundcraft_engine::file_access::RootError;
|
||||
|
||||
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum CliError {
|
||||
#[error(transparent)]
|
||||
Convert(#[from] ConvertError),
|
||||
#[error(transparent)]
|
||||
Engine(#[from] EngineError),
|
||||
#[error("unknown command `{0}`")]
|
||||
UnknownCommand(String),
|
||||
#[error("{id}: {source}")]
|
||||
Command { id: String, source: EngineError },
|
||||
#[error("{}:{line}: bad JSON: {source}", path.display())]
|
||||
ScriptJson { path: PathBuf, line: usize, source: serde_json::Error },
|
||||
#[error("{}:{line}: {id}: {source}", path.display())]
|
||||
ScriptLine { path: PathBuf, line: usize, id: String, source: EngineError },
|
||||
#[error("{}: {source}", path.display())]
|
||||
File { path: PathBuf, source: std::io::Error },
|
||||
#[error("{}: {source}", path.display())]
|
||||
Audio { path: PathBuf, source: AudioError },
|
||||
/// A `--bounce` or `--save` path: the engine's commands take them in JSON, which holds only
|
||||
/// text.
|
||||
#[error("{}: the path is not valid Unicode", .0.display())]
|
||||
NotUnicode(PathBuf),
|
||||
#[error(transparent)]
|
||||
Remote(#[from] RemoteError),
|
||||
#[error(transparent)]
|
||||
Roots(#[from] RootError),
|
||||
#[error(transparent)]
|
||||
Serve(std::io::Error),
|
||||
}
|
||||
|
||||
/// A path as the text the engine's commands take.
|
||||
pub(crate) fn unicode(path: &Path) -> Result<&str, CliError> {
|
||||
path.to_str().ok_or_else(|| CliError::NotUnicode(path.to_owned()))
|
||||
}
|
||||
|
||||
/// A `convert` option or output the tool refuses.
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub(crate) enum ConvertError {
|
||||
#[error("{}: cannot write `.{ext}` files (supported: {})", output.display(), soundcraft_audio_io::FileFormat::writable_extensions().join(", "))]
|
||||
OutputFormat { output: PathBuf, ext: String },
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use super::args::InfoArgs;
|
||||
use super::error::CliError;
|
||||
use super::output::print_json;
|
||||
use super::session::warn_missing;
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
use std::io::Write;
|
||||
|
||||
pub(crate) fn info(args: &InfoArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
|
||||
let path = &args.file;
|
||||
if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("scraft")) {
|
||||
let mut e = Engine::default();
|
||||
let missing = soundcraft_engine::io::open_session(&mut e, path)?;
|
||||
warn_missing(err, &missing);
|
||||
outln!(out, "{}", soundcraft_engine::inspect::session_text(&e));
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|source| CliError::File { path: path.clone(), source })?;
|
||||
let ext = path.extension().and_then(std::ffi::OsStr::to_str);
|
||||
let i = soundcraft_audio_io::probe(&bytes, ext).map_err(|source| CliError::Audio { path: path.clone(), source })?;
|
||||
print_json(
|
||||
out,
|
||||
&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf}),
|
||||
args.compact,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! `soundcraft-cli`: SoundCraft without a window. `run` is the whole program, so it can be tested
|
||||
//! in-process as well as through the binary. `soundcraft-cli --help` lists the subcommands.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[macro_use]
|
||||
mod output;
|
||||
|
||||
mod app;
|
||||
mod args;
|
||||
mod catalog;
|
||||
mod convert;
|
||||
mod error;
|
||||
mod info;
|
||||
mod mcp;
|
||||
mod session;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::Write;
|
||||
|
||||
use clap::CommandFactory;
|
||||
|
||||
use app::app;
|
||||
use args::{Cli, Command, parse};
|
||||
use catalog::{commands, describe, parity};
|
||||
use convert::convert;
|
||||
use error::CliError;
|
||||
use info::info;
|
||||
use mcp::mcp;
|
||||
use session::{run_cmds, script};
|
||||
|
||||
/// The command line's clap definition.
|
||||
pub fn command() -> clap::Command {
|
||||
Cli::command()
|
||||
}
|
||||
|
||||
/// Run the CLI on its arguments (without the program name), writing results to `out` and
|
||||
/// diagnostics to `err`. Returns the process exit code: 0 on success, 1 on failure, 2 for a usage
|
||||
/// error. Help and the version go to `out`, exit 0. Plugin hosts are shut down by the caller once
|
||||
/// the process is done with every engine (see `main`).
|
||||
pub fn run<I, T>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
|
||||
// Help and the version (`help`, `-h`, `<subcommand> --help`, `-V`) come back as errors too.
|
||||
let cli = match parse(&args) {
|
||||
Ok(cli) => cli,
|
||||
Err(e) => {
|
||||
let to: &mut dyn Write = if e.use_stderr() { err } else { out };
|
||||
let _ = write!(to, "{}", e.render());
|
||||
return e.exit_code();
|
||||
}
|
||||
};
|
||||
match dispatch(cli.command, out, err) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
outln!(err, "error: {e}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(command: Command, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
|
||||
match command {
|
||||
Command::Info(a) => info(&a, out, err)?,
|
||||
Command::Convert(a) => convert(&a, out)?,
|
||||
Command::Run(a) => run_cmds(&a, out, err)?,
|
||||
Command::Script(a) => script(&a, out, err)?,
|
||||
Command::Commands(a) => commands(&a, out),
|
||||
Command::Describe(a) => describe(&a, out)?,
|
||||
Command::App(a) => app(&a, out)?,
|
||||
Command::Mcp(a) => mcp(&a, err)?,
|
||||
Command::Parity(a) => parity(&a, out)?,
|
||||
Command::Plugins => {
|
||||
for p in soundcraft_dsp::plugins() {
|
||||
outln!(out, "{:<20} {:<22} {:?}{}", p.id, p.name, p.category, if p.is_instrument { " (instrument)" } else { "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,684 +1,12 @@
|
||||
//! `soundcraft-cli`: SoundCraft without a window.
|
||||
//!
|
||||
//! ```text
|
||||
//! soundcraft-cli info FILE audio file or .scraft summary
|
||||
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] [--dither | --no-dither]
|
||||
//! dithers only when the output loses resolution, unless told
|
||||
//! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect]
|
||||
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT [--start T --end T]] (one `ID JSON` per line;
|
||||
//! without --start/--end, --bounce covers the whole session)
|
||||
//! soundcraft-cli commands [FILTER] list commands
|
||||
//! soundcraft-cli describe ID one command's details
|
||||
//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel
|
||||
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
//! MCP server on stdio. The roots confine every file the
|
||||
//! agent's commands read (open, import, presets) and write
|
||||
//! (save, bounce, export) to those folders, links followed;
|
||||
//! each flag may be given more than once, a path inside any
|
||||
//! of its folders being allowed, and a kind left out grants
|
||||
//! none of its access. They run the in-process server, so
|
||||
//! they conflict with --connect.
|
||||
//! soundcraft-cli parity [--write PATH] parity report (markdown)
|
||||
//! soundcraft-cli plugins list built-in plugins
|
||||
//! ```
|
||||
//! `soundcraft-cli`: SoundCraft without a window. See the library crate for the commands.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod automation_args;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_automation::{Backend, Headless, Remote, mcp::Server};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_engine::file_access::{self, AutomationRoots};
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// Print without panicking on a closed pipe. A write error must not skip later work
|
||||
/// such as `--bounce` or `--save`, and must not by itself report success.
|
||||
macro_rules! outln {
|
||||
($($t:tt)*) => {{
|
||||
let mut o = std::io::stdout().lock();
|
||||
let _ = writeln!(o, $($t)*);
|
||||
}};
|
||||
}
|
||||
|
||||
fn arg_value(args: &[String], flag: &str) -> Option<String> {
|
||||
args.iter().enumerate().find_map(|(i, a)| {
|
||||
if a == flag { args.get(i + 1).cloned() } else { a.strip_prefix(flag).and_then(|rest| rest.strip_prefix('=')).map(str::to_owned) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Every option a subcommand accepts, with whether it carries a value.
|
||||
type Options = [(&'static str, bool)];
|
||||
|
||||
/// A misspelled option, or one whose value is missing, reads as absent through `arg_value`, so the
|
||||
/// subcommand would run without what was asked for and still report success. Reject both instead.
|
||||
fn check_options(args: &[String], allowed: &Options) -> Result<(), String> {
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let Some(name) = args[i].strip_prefix("--") else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let (name, inline) = name.split_once('=').map_or((name, None), |(n, v)| (n, Some(v)));
|
||||
let flag = format!("--{name}");
|
||||
let Some(&(_, takes_value)) = allowed.iter().find(|(f, _)| f == &flag) else {
|
||||
return Err(format!("unknown option {flag}"));
|
||||
};
|
||||
match (takes_value, inline) {
|
||||
(false, Some(_)) => return Err(format!("{flag} takes no value")),
|
||||
(true, None) => {
|
||||
// A following option is the next flag, not this one's value.
|
||||
if args.get(i + 1).is_none_or(|v| v.starts_with("--")) {
|
||||
return Err(format!("{flag} needs a value"));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--in`, `--demo`/`--sample`, `--bounce`, `--start`, `--end`, `--save` and `--inspect`, read by
|
||||
/// `load_engine` and `finish` and so shared by `run` and `script`.
|
||||
const ENGINE_OPTIONS: &Options = &[
|
||||
("--in", true),
|
||||
("--demo", false),
|
||||
("--sample", false),
|
||||
("--bounce", true),
|
||||
("--start", true),
|
||||
("--end", true),
|
||||
("--save", true),
|
||||
("--inspect", false),
|
||||
];
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ExitCode {
|
||||
eprintln!("soundcraft-cli: {msg}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
/// The usage block of the module doc comment, up to its closing code fence.
|
||||
fn help_text() -> String {
|
||||
include_str!("main.rs")
|
||||
.lines()
|
||||
.skip(3)
|
||||
.take_while(|l| l.trim_end() != "//! ```")
|
||||
.map(|l| l.trim_start_matches("//! "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let Some(cmd) = args.first().cloned() else {
|
||||
eprintln!("usage: soundcraft-cli <info|convert|run|script|commands|describe|app|mcp|parity|plugins> … (see --help)");
|
||||
return ExitCode::FAILURE;
|
||||
};
|
||||
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
|
||||
let code = match cmd.as_str() {
|
||||
"--version" | "-V" | "version" => {
|
||||
outln!("SoundCraft CLI {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
"--help" | "-h" | "help" => {
|
||||
outln!("{}", help_text());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
"info" => info(&rest),
|
||||
"convert" => convert(&rest),
|
||||
"run" => run(&rest),
|
||||
"script" => script(&rest),
|
||||
"commands" => commands(&rest),
|
||||
"describe" => describe(&rest),
|
||||
"app" => app(&rest),
|
||||
"mcp" => mcp(&rest),
|
||||
"parity" => parity(&rest),
|
||||
"plugins" => {
|
||||
for p in soundcraft_dsp::plugins() {
|
||||
outln!("{:<20} {:<22} {:?}{}", p.id, p.name, p.category, if p.is_instrument { " (instrument)" } else { "" });
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
other => fail(format!("unknown subcommand `{other}`")),
|
||||
};
|
||||
// The subcommand's engine and its plugin instances are gone by now.
|
||||
fn main() {
|
||||
// `args_os`, not `args`: a non-Unicode argument must reach clap, not panic before anything is printed.
|
||||
let code = soundcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
|
||||
// The subcommand's engine and its plugin instances are gone by now. This is process teardown,
|
||||
// so it lives here and not in `run`, which tests call many times in one process.
|
||||
soundcraft_engine::shutdown_plugin_hosts();
|
||||
code
|
||||
}
|
||||
|
||||
fn info(args: &[String]) -> ExitCode {
|
||||
let Some(path) = args.first() else { return fail("info FILE") };
|
||||
if path.ends_with(".scraft") {
|
||||
let mut e = Engine::default();
|
||||
match soundcraft_engine::io::open_session(&mut e, path) {
|
||||
Ok(missing) => warn_missing(&missing),
|
||||
Err(err) => return fail(err),
|
||||
}
|
||||
outln!("{}", soundcraft_engine::inspect::session_text(&e));
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(format!("{path}: {e}")),
|
||||
};
|
||||
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str);
|
||||
match soundcraft_audio_io::probe(&bytes, ext) {
|
||||
Ok(i) => {
|
||||
outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => fail(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(args: &[String]) -> ExitCode {
|
||||
let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") };
|
||||
const OPTIONS: &Options = &[("--rate", true), ("--bit-depth", true), ("--normalize", false), ("--dither", false), ("--no-dither", false)];
|
||||
if let Err(e) = check_options(&args[2..], OPTIONS) {
|
||||
return fail(e);
|
||||
}
|
||||
let rate = match parse_rate(arg_value(args, "--rate").as_deref()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let bit_depth = match parse_bit_depth(arg_value(args, "--bit-depth").as_deref()) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let dither_flag = match parse_dither(args) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let bytes = match std::fs::read(input) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(format!("{input}: {e}")),
|
||||
};
|
||||
let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str);
|
||||
let (info, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return fail(format!("{input}: {e}")),
|
||||
};
|
||||
if let Some(rate) = rate {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
|
||||
buf.sample_rate = rate;
|
||||
}
|
||||
let normalize = args.iter().any(|a| a == "--normalize");
|
||||
if normalize {
|
||||
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
|
||||
}
|
||||
let processed = rate.is_some_and(|r| r != info.sample_rate) || normalize;
|
||||
let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed));
|
||||
let out_ext = std::path::Path::new(output).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
|
||||
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
|
||||
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
|
||||
};
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
|
||||
match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) {
|
||||
Ok(()) => {
|
||||
outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => fail(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rate(v: Option<&str>) -> Result<Option<u32>, String> {
|
||||
match v {
|
||||
None => Ok(None),
|
||||
Some(r) => match r.parse::<u32>() {
|
||||
Ok(hz) if hz > 0 => Ok(Some(hz)),
|
||||
_ => Err(format!("--rate: expected a sample rate in Hz, got `{r}`")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bit_depth(v: Option<&str>) -> Result<soundcraft_audio_io::BitDepth, String> {
|
||||
match v {
|
||||
None | Some("24") => Ok(soundcraft_audio_io::BitDepth::Int24),
|
||||
Some("16") => Ok(soundcraft_audio_io::BitDepth::Int16),
|
||||
Some("32") | Some("32f") => Ok(soundcraft_audio_io::BitDepth::Float32),
|
||||
Some(b) => Err(format!("--bit-depth: expected 16, 24 or 32f, got `{b}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--dither` / `--no-dither`: `Some` when the caller chose, `None` to decide from the depths.
|
||||
fn parse_dither(args: &[String]) -> Result<Option<bool>, String> {
|
||||
match (args.iter().any(|a| a == "--dither"), args.iter().any(|a| a == "--no-dither")) {
|
||||
(true, true) => Err("--dither and --no-dither: choose one".into()),
|
||||
(true, false) => Ok(Some(true)),
|
||||
(false, true) => Ok(Some(false)),
|
||||
(false, false) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `convert` dithers when not told: only when writing integer samples that cannot hold the
|
||||
/// source exactly, i.e. the output has fewer bits than the source, the source is float or lossy, or
|
||||
/// resampling/normalizing produced new sample values. A 16-bit file written as 16 or 24-bit, or
|
||||
/// anything written as 32-bit float, is copied sample for sample (digital silence stays silent).
|
||||
fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_audio_io::BitDepth, processed: bool) -> bool {
|
||||
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
||||
let target_bits = match target {
|
||||
BitDepth::Float32 => return false,
|
||||
BitDepth::Int16 => 16,
|
||||
BitDepth::Int24 => 24,
|
||||
BitDepth::Int32 => 32,
|
||||
};
|
||||
if processed {
|
||||
return true;
|
||||
}
|
||||
match source {
|
||||
SampleFormat::Int8 => target_bits < 8,
|
||||
SampleFormat::Int16 => target_bits < 16,
|
||||
SampleFormat::Int24 => target_bits < 24,
|
||||
// Decoded through f32, which holds 24 bits: a 32-bit integer source has already lost
|
||||
// resolution, so treat it like float.
|
||||
SampleFormat::Int32 | SampleFormat::Float32 | SampleFormat::Float64 | SampleFormat::Compressed => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
|
||||
fn missing_warnings(missing: &[String]) -> Vec<String> {
|
||||
missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
|
||||
}
|
||||
|
||||
fn warn_missing(missing: &[String]) {
|
||||
for line in missing_warnings(missing) {
|
||||
eprintln!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn load_engine(args: &[String]) -> Result<Engine, String> {
|
||||
if let Some(p) = arg_value(args, "--in") {
|
||||
let mut e = Engine::default();
|
||||
let missing = soundcraft_engine::io::open_session(&mut e, &p).map_err(|e| e.to_string())?;
|
||||
warn_missing(&missing);
|
||||
Ok(e)
|
||||
} else if args.iter().any(|a| a == "--demo" || a == "--sample") {
|
||||
Ok(soundcraft_engine::demo::demo_engine())
|
||||
} else {
|
||||
Ok(Engine::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
|
||||
if let Some(out) = arg_value(args, "--bounce") {
|
||||
let mut p = json!({"path": out});
|
||||
if let Some(s) = arg_value(args, "--start") {
|
||||
p["start"] = time_arg(&s);
|
||||
}
|
||||
if let Some(s) = arg_value(args, "--end") {
|
||||
p["end"] = time_arg(&s);
|
||||
}
|
||||
// No range given: bounce the whole session, not whatever edit selection the commands left.
|
||||
if p.get("start").is_none() && p.get("end").is_none() {
|
||||
p["start"] = json!(0);
|
||||
p["end"] = json!(e.session().content_end().max(1));
|
||||
}
|
||||
match e.execute("file.bounce_mix", &p) {
|
||||
Ok(v) => outln!("{v}"),
|
||||
Err(err) => return fail(err),
|
||||
}
|
||||
}
|
||||
if let Some(path) = arg_value(args, "--save") {
|
||||
match e.execute("session.save", &json!({"path": path, "replace": true})) {
|
||||
Ok(v) => outln!("{v}"),
|
||||
Err(err) => return fail(err),
|
||||
}
|
||||
}
|
||||
if args.iter().any(|a| a == "--inspect") {
|
||||
outln!("{}", serde_json::to_string_pretty(&soundcraft_engine::inspect::session(e, false)).unwrap_or_default());
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// `12.5` → seconds; anything else is passed through as a time string.
|
||||
fn time_arg(s: &str) -> Value {
|
||||
s.parse::<f64>().map_or_else(|_| json!(s), |f| json!({"seconds": f}))
|
||||
}
|
||||
|
||||
/// `ID` or `ID=JSON` → command id and params; malformed JSON is an error, not `{}`.
|
||||
fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> {
|
||||
match spec.split_once('=') {
|
||||
Some((id, p)) => serde_json::from_str::<Value>(p).map(|v| (id.to_string(), v)).map_err(|err| format!("{id}: bad JSON: {err}")),
|
||||
None => Ok((spec.to_string(), json!({}))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> ExitCode {
|
||||
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--cmd", true)]).collect();
|
||||
if let Err(e) = check_options(args, &options) {
|
||||
return fail(e);
|
||||
}
|
||||
let mut e = match load_engine(args) {
|
||||
Ok(e) => e,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let spec = match a.strip_prefix("--cmd=") {
|
||||
Some(inline) => inline,
|
||||
None if a == "--cmd" => match it.next() {
|
||||
Some(next) => next,
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
let (id, params) = match parse_cmd_spec(spec) {
|
||||
Ok(v) => v,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
match e.execute(&id, ¶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"));
|
||||
}
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
use super::args::McpArgs;
|
||||
use super::error::CliError;
|
||||
use soundcraft_automation::{ControlTarget, Headless, Remote, mcp::Server};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_engine::file_access::{self, AutomationRoots};
|
||||
use std::io::Write;
|
||||
|
||||
/// What `mcp` serves.
|
||||
struct Setup {
|
||||
connect: Option<ControlTarget>,
|
||||
demo: bool,
|
||||
roots: Option<std::sync::Arc<AutomationRoots>>,
|
||||
}
|
||||
|
||||
/// Open the roots before the server starts: a folder that can't be opened must not leave the
|
||||
/// server running unconfined.
|
||||
fn setup(args: &McpArgs) -> Result<Setup, CliError> {
|
||||
let read: Vec<&std::path::Path> = args.automation.automation_read_root.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let write: Vec<&std::path::Path> = args.automation.automation_write_root.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let roots = AutomationRoots::new(&read, &write)?;
|
||||
Ok(Setup { connect: args.connect.clone(), demo: args.demo, roots })
|
||||
}
|
||||
|
||||
/// Serves on the process's real stdin and stdout; only the notes go to `err`.
|
||||
pub(crate) fn mcp(args: &McpArgs, err: &mut dyn Write) -> Result<(), CliError> {
|
||||
let Setup { connect, demo, roots } = setup(args)?;
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
let served = if let Some(target) = connect {
|
||||
let mut s = Server::new(Remote::new(&target));
|
||||
s.serve(stdin.lock(), stdout.lock())
|
||||
} else {
|
||||
// `--demo` is the operator's own choice, made before the roots come into force.
|
||||
let e = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
||||
let mut h = Headless::new(e);
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
outln!(err, "soundcraft-cli: files confined: {}", roots.describe());
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
let mut s = Server::new(h);
|
||||
s.serve(stdin.lock(), stdout.lock())
|
||||
};
|
||||
served.map_err(CliError::Serve)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/// Print to a writer without panicking on a closed pipe. A write error must not skip later work
|
||||
/// such as `--bounce` or `--save`, and must not by itself report success.
|
||||
macro_rules! outln {
|
||||
($w:expr, $($t:tt)*) => {{
|
||||
let _ = writeln!($w, $($t)*);
|
||||
}};
|
||||
}
|
||||
|
||||
/// Print `value` as JSON: pretty-printed, or on one line when `compact`. Every subcommand that
|
||||
/// prints a JSON document goes through here, so `--compact` means the same thing everywhere.
|
||||
pub(crate) fn print_json(out: &mut dyn std::io::Write, value: &impl serde::Serialize, compact: bool) {
|
||||
let text = if compact { serde_json::to_string(value) } else { serde_json::to_string_pretty(value) };
|
||||
outln!(out, "{}", text.unwrap_or_default());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn compact_is_one_line_and_pretty_is_the_same_value_over_several() {
|
||||
let v = json!({"a": [1, 2], "b": {"c": "d"}});
|
||||
let (mut pretty, mut compact) = (Vec::new(), Vec::new());
|
||||
print_json(&mut pretty, &v, false);
|
||||
print_json(&mut compact, &v, true);
|
||||
let (pretty, compact) = (String::from_utf8(pretty).unwrap_or_default(), String::from_utf8(compact).unwrap_or_default());
|
||||
assert!(pretty.trim_end().lines().count() > 1);
|
||||
assert_eq!(compact, "{\"a\":[1,2],\"b\":{\"c\":\"d\"}}\n");
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&pretty).ok(), Some(v));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use super::args::{CmdSpec, EngineArgs, RunArgs, ScriptArgs};
|
||||
use super::error::{CliError, unicode};
|
||||
use super::output::print_json;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_engine::Engine;
|
||||
use std::io::Write;
|
||||
|
||||
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
|
||||
pub(crate) fn warn_missing(err: &mut dyn Write, missing: &[String]) {
|
||||
for m in missing {
|
||||
outln!(err, "warning: missing media: {m}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The outputs go to the engine as JSON text, so a non-Unicode one is refused before any command runs.
|
||||
fn load_engine(args: &EngineArgs, err: &mut dyn Write) -> Result<Engine, CliError> {
|
||||
if let Some(bounce) = &args.bounce {
|
||||
unicode(bounce)?;
|
||||
}
|
||||
if let Some(save) = args.save_path() {
|
||||
unicode(&save)?;
|
||||
}
|
||||
if let Some(p) = &args.input {
|
||||
let mut e = Engine::default();
|
||||
let missing = soundcraft_engine::io::open_session(&mut e, p)?;
|
||||
warn_missing(err, &missing);
|
||||
Ok(e)
|
||||
} else if args.demo {
|
||||
Ok(soundcraft_engine::demo::demo_engine())
|
||||
} else {
|
||||
Ok(Engine::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(e: &mut Engine, args: &EngineArgs, out: &mut dyn Write) -> Result<(), CliError> {
|
||||
if let Some(path) = &args.bounce {
|
||||
let mut p = json!({"path": unicode(path)?});
|
||||
if let Some(s) = &args.start {
|
||||
p["start"] = s.to_json();
|
||||
}
|
||||
if let Some(s) = &args.end {
|
||||
p["end"] = s.to_json();
|
||||
}
|
||||
// No range given: bounce the whole session, not whatever edit selection the commands left.
|
||||
if p.get("start").is_none() && p.get("end").is_none() {
|
||||
p["start"] = json!(0);
|
||||
p["end"] = json!(e.session().content_end().max(1));
|
||||
}
|
||||
let v = e.execute("file.bounce_mix", &p)?;
|
||||
outln!(out, "{v}");
|
||||
}
|
||||
if let Some(path) = args.save_path() {
|
||||
let v = e.execute("session.save", &json!({"path": unicode(&path)?, "replace": true}))?;
|
||||
outln!(out, "{v}");
|
||||
}
|
||||
if args.inspect {
|
||||
print_json(out, &soundcraft_engine::inspect::session(e, false), args.compact);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_cmds(args: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
|
||||
let mut e = load_engine(&args.engine, err)?;
|
||||
for CmdSpec { id, params } in &args.cmd {
|
||||
match e.execute(id, params) {
|
||||
Ok(v) => outln!(out, "{id}: {v}"),
|
||||
Err(source) => return Err(CliError::Command { id: id.clone(), source }),
|
||||
}
|
||||
}
|
||||
finish(&mut e, &args.engine, out)
|
||||
}
|
||||
|
||||
pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
|
||||
let path = &args.file;
|
||||
let text = std::fs::read_to_string(path).map_err(|source| CliError::File { path: path.clone(), source })?;
|
||||
let mut e = load_engine(&args.engine, err)?;
|
||||
let keep = args.keep_going;
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let (id, p) = line.split_once(char::is_whitespace).unwrap_or((line, "{}"));
|
||||
let params = serde_json::from_str::<Value>(p.trim()).map_err(|source| CliError::ScriptJson { path: path.clone(), line: n + 1, source })?;
|
||||
match e.execute(id, ¶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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
finish(&mut e, &args.engine, out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_media_gets_one_warning_each() {
|
||||
let mut err = Vec::new();
|
||||
warn_missing(&mut err, &[]);
|
||||
assert!(err.is_empty());
|
||||
warn_missing(&mut err, &["a.wav".to_string(), "b.wav: decode failed".to_string()]);
|
||||
assert_eq!(String::from_utf8(err).unwrap(), "warning: missing media: a.wav\nwarning: missing media: b.wav: decode failed\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
//! How `soundcraft-cli` parses its arguments and reports results today, run as the real binary.
|
||||
//! A change to the command line's behaviour updates them on purpose.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
|
||||
|
||||
/// What one run of the binary produced.
|
||||
struct Invoke {
|
||||
code: i32,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
/// Runs the binary in `dir` with empty stdin and no control-port variable.
|
||||
fn invoke(dir: &Path, args: &[&str]) -> Invoke {
|
||||
invoke_env(dir, args, None)
|
||||
}
|
||||
|
||||
fn invoke_env(dir: &Path, args: &[&str], control_port: Option<&str>) -> Invoke {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
|
||||
cmd.env("SOUNDCRAFT_NO_PREFS", "1").env_remove("SOUNDCRAFT_CONTROL_PORT");
|
||||
if let Some(port) = control_port {
|
||||
cmd.env("SOUNDCRAFT_CONTROL_PORT", port);
|
||||
}
|
||||
let out = cmd.current_dir(dir).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
Invoke { code: out.status.code().unwrap(), stdout: String::from_utf8(out.stdout).unwrap(), stderr: String::from_utf8(out.stderr).unwrap() }
|
||||
}
|
||||
|
||||
/// A fresh directory holding `in.wav` (200 samples, 16-bit mono, 48 kHz) and `s.txt` (a one-command script).
|
||||
fn sandbox(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-behaviour-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut buf = AudioBuffer::new(48_000, 1, 200);
|
||||
for (i, d) in buf.channels[0].iter_mut().enumerate() {
|
||||
*d = i as f32 / 400.0;
|
||||
}
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
std::fs::write(dir.join("in.wav"), soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
std::fs::write(dir.join("s.txt"), "track.new {}\n").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// The sample rate of the WAV `name` in `dir`.
|
||||
fn rate_of(dir: &Path, name: &str) -> u32 {
|
||||
soundcraft_audio_io::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_rate
|
||||
}
|
||||
|
||||
/// Bit depth of the WAV `name` in `dir`, as the probe reports the sample format.
|
||||
fn format_of(dir: &Path, name: &str) -> String {
|
||||
format!("{:?}", soundcraft_audio_io::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_format)
|
||||
}
|
||||
|
||||
/// Exit 1, nothing on stdout, and exactly `error: {message}` on stderr.
|
||||
#[track_caller]
|
||||
fn assert_fails(got: &Invoke, message: &str) {
|
||||
assert_eq!(got.code, 1, "stderr: {}", got.stderr);
|
||||
assert_eq!(got.stdout, "");
|
||||
assert_eq!(got.stderr, format!("error: {message}\n"));
|
||||
}
|
||||
|
||||
/// Exit 0 with nothing on either stream.
|
||||
#[track_caller]
|
||||
fn assert_silent_success(got: &Invoke) {
|
||||
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "", ""));
|
||||
}
|
||||
|
||||
/// Exit 2, nothing on stdout, and a clap usage error starting with `error: {message}` on stderr.
|
||||
#[track_caller]
|
||||
fn assert_usage_error(got: &Invoke, message: &str) {
|
||||
assert_eq!(got.code, 2, "stderr: {}", got.stderr);
|
||||
assert_eq!(got.stdout, "");
|
||||
assert!(got.stderr.starts_with(&format!("error: {message}")), "{}", got.stderr);
|
||||
}
|
||||
|
||||
/// The OS's message for a missing file: error 2 is `ENOENT` on Unix and `ERROR_FILE_NOT_FOUND` on Windows.
|
||||
fn not_found() -> std::io::Error {
|
||||
std::io::Error::from_raw_os_error(2)
|
||||
}
|
||||
|
||||
/// Exit 1 with the refused-connection message for `port`; the OS's text for the refusal differs by platform.
|
||||
#[track_caller]
|
||||
fn assert_refused(got: &Invoke, port: u16) {
|
||||
assert_eq!((got.code, got.stdout.as_str()), (1, ""));
|
||||
let prefix = format!("error: cannot reach SoundCraft at 127.0.0.1:{port}: ");
|
||||
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
|
||||
}
|
||||
|
||||
// ---- unknown flags ----
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_stop_the_work_before_it_starts() {
|
||||
let dir = sandbox("unknown-flag-reject");
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "out.wav", "--bogus"]), "unexpected argument '--bogus' found");
|
||||
assert_usage_error(&invoke(&dir, &["run", "--bogus", "--save", "s.scraft"]), "unexpected argument '--bogus' found");
|
||||
assert_usage_error(&invoke(&dir, &["script", "s.txt", "--bogus"]), "unexpected argument '--bogus' found");
|
||||
// A single-dash word is a flag too.
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "out.wav", "-x"]), "unexpected argument '-x' found");
|
||||
assert!(!dir.join("out.wav").exists() && !dir.join("s.scraft").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_flags_may_come_before_or_between_the_positionals() {
|
||||
let dir = sandbox("convert-order");
|
||||
let got = invoke(&dir, &["convert", "--rate", "44100", "in.wav", "a.wav"]);
|
||||
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "wrote a.wav (1 ch, 44100 Hz, 0.00 s)\n", ""));
|
||||
let got = invoke(&dir, &["convert", "in.wav", "--rate", "44100", "b.wav"]);
|
||||
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "wrote b.wav (1 ch, 44100 Hz, 0.00 s)\n", ""));
|
||||
assert_eq!((rate_of(&dir, "a.wav"), rate_of(&dir, "b.wav")), (44_100, 44_100));
|
||||
}
|
||||
|
||||
// ---- value flags ----
|
||||
|
||||
#[test]
|
||||
fn convert_value_flags_take_a_separate_or_inline_value() {
|
||||
let dir = sandbox("convert-values");
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "c.wav", "--rate", "44100"]).stdout, "wrote c.wav (1 ch, 44100 Hz, 0.00 s)\n");
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "d.wav", "--rate=44100"]).stdout, "wrote d.wav (1 ch, 44100 Hz, 0.00 s)\n");
|
||||
assert_eq!(rate_of(&dir, "c.wav"), 44_100);
|
||||
assert_eq!(rate_of(&dir, "d.wav"), 44_100);
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "e.wav", "--bit-depth", "16"]).code, 0);
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "f.wav", "--bit-depth=32f"]).code, 0);
|
||||
assert_eq!(format_of(&dir, "e.wav"), "Int16");
|
||||
assert_eq!(format_of(&dir, "f.wav"), "Float32");
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "h.wav", "--bit-depth", "32"]).code, 0);
|
||||
assert_eq!(format_of(&dir, "h.wav"), "Float32");
|
||||
assert_eq!(invoke(&dir, &["convert", "in.wav", "g.wav"]).code, 0);
|
||||
assert_eq!(format_of(&dir, "g.wav"), "Int24");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_value_flag_without_a_value_or_with_a_bad_one_fails() {
|
||||
let dir = sandbox("convert-bad-values");
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--rate"]), "a value is required for '--rate <HZ>' but none was supplied");
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--bit-depth"]), "a value is required for '--bit-depth <16|24|32f>'");
|
||||
// A following flag is the next flag, not the value.
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--rate", "--normalize"]), "a value is required for '--rate <HZ>'");
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "o.wav", "--normalize=1"]), "unexpected value '1' for '--normalize' found");
|
||||
assert!(!dir.join("o.wav").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_flags_are_usage_errors_in_either_spelling() {
|
||||
let dir = sandbox("repeat");
|
||||
for args in [
|
||||
&["convert", "in.wav", "a.wav", "--rate", "44100", "--rate", "22050"][..],
|
||||
&["convert", "in.wav", "a.wav", "--rate=22050", "--rate", "44100"],
|
||||
&["convert", "in.wav", "a.wav", "--bit-depth", "16", "--bit-depth", "24"],
|
||||
&["convert", "in.wav", "a.wav", "--normalize", "--normalize"],
|
||||
&["run", "--save", "a.scraft", "--save", "b.scraft"],
|
||||
&["run", "--demo", "--demo"],
|
||||
&["run", "--demo", "--sample"],
|
||||
&["script", "s.txt", "--keep-going", "--keep-going"],
|
||||
&["app", "--port", "1", "--port", "2", "x"],
|
||||
&["mcp", "--demo", "--demo"],
|
||||
] {
|
||||
let got = invoke(&dir, args);
|
||||
assert_usage_error(&got, "the argument '--");
|
||||
assert_eq!(got.code, 2, "{args:?}");
|
||||
}
|
||||
assert!(!dir.join("a.wav").exists() && !dir.join("a.scraft").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_input_and_output_errors() {
|
||||
let dir = sandbox("convert-io");
|
||||
assert_fails(&invoke(&dir, &["convert", "nope.wav", "o.wav"]), &format!("nope.wav: {}", not_found()));
|
||||
assert_fails(&invoke(&dir, &["convert", "in.wav", "o.xyz"]), "o.xyz: cannot write `.xyz` files (supported: wav, wave, bwf, aif, aiff, flac)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_value_flags_take_a_separate_or_inline_value() {
|
||||
let dir = sandbox("run-values");
|
||||
let tracks = "track.new: {\"tracks\":[1]}\n";
|
||||
assert_eq!(invoke(&dir, &["run", "--cmd", "track.new"]).stdout, tracks);
|
||||
assert_eq!(invoke(&dir, &["run", "--cmd=track.new"]).stdout, tracks);
|
||||
let save = invoke(&dir, &["run", "--save", "s1.scraft"]);
|
||||
assert_eq!((save.code, save.stdout.as_str()), (0, "{\"audio_files_written\":0,\"path\":\"s1.scraft\"}\n"));
|
||||
let save = invoke(&dir, &["run", "--save=s2.scraft"]);
|
||||
assert_eq!((save.code, save.stdout.as_str()), (0, "{\"audio_files_written\":0,\"path\":\"s2.scraft\"}\n"));
|
||||
assert!(dir.join("s1.scraft").exists() && dir.join("s2.scraft").exists());
|
||||
// --in in both spellings loads the saved session.
|
||||
for args in [["run", "--in", "s1.scraft", "--inspect"], ["run", "--in=s1.scraft", "--inspect", ""]] {
|
||||
let args: Vec<&str> = args.into_iter().filter(|a| !a.is_empty()).collect();
|
||||
let got = invoke(&dir, &args);
|
||||
assert_eq!(got.code, 0, "{args:?}: {}", got.stderr);
|
||||
assert!(got.stdout.contains("\"dirty\": false"), "{args:?}: {}", got.stdout);
|
||||
}
|
||||
// --bounce with --start/--end in both spellings.
|
||||
let spaced = invoke(&dir, &["run", "--demo", "--bounce", "b1.wav", "--start", "0", "--end", "0.1"]);
|
||||
assert_eq!(spaced.code, 0, "{}", spaced.stderr);
|
||||
assert!(spaced.stdout.contains("\"path\":\"b1.wav\"") && spaced.stdout.contains("\"seconds\":0.1"), "{}", spaced.stdout);
|
||||
let inline = invoke(&dir, &["run", "--demo", "--bounce=b2.wav", "--start=0", "--end=0.1"]);
|
||||
assert_eq!(inline.code, 0, "{}", inline.stderr);
|
||||
assert!(inline.stdout.contains("\"path\":\"b2.wav\"") && inline.stdout.contains("\"seconds\":0.1"), "{}", inline.stdout);
|
||||
assert!(dir.join("b1.wav").exists() && dir.join("b2.wav").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cmd_repeats_and_runs_every_occurrence_in_order() {
|
||||
let dir = sandbox("run-repeat");
|
||||
assert_eq!(invoke(&dir, &["run", "--cmd", "track.new", "--cmd=track.new"]).stdout, "track.new: {\"tracks\":[1]}\ntrack.new: {\"tracks\":[2]}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_times_are_values_but_other_dash_words_are_not() {
|
||||
let dir = sandbox("negative-time");
|
||||
// The engine counts a position before 0, so the bounce starts a second ahead of the session.
|
||||
for args in [&["--start", "-1"][..], &["--start=-1"]] {
|
||||
let mut full = vec!["run", "--demo", "--end", "0.1", "--bounce", "b.wav"];
|
||||
full.extend(args);
|
||||
let got = invoke(&dir, &full);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert!(got.stdout.contains("\"seconds\":1.1"), "{}", got.stdout);
|
||||
}
|
||||
assert_usage_error(&invoke(&dir, &["run", "--bounce", "-x.wav"]), "unexpected argument '-x' found");
|
||||
assert_usage_error(&invoke(&dir, &["run", "--cmd", "-x"]), "unexpected argument '-x' found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_runs_lines_and_takes_options_before_or_after_the_file() {
|
||||
let dir = sandbox("script");
|
||||
let got = invoke(&dir, &["script", "s.txt"]);
|
||||
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "track.new: {\"tracks\":[1]}\n", ""));
|
||||
let got = invoke(&dir, &["script", "s.txt", "--save", "sv.scraft"]);
|
||||
assert_eq!(got.stdout, "track.new: {\"tracks\":[1]}\n{\"audio_files_written\":0,\"path\":\"sv.scraft\"}\n");
|
||||
assert!(dir.join("sv.scraft").exists());
|
||||
assert_eq!(invoke(&dir, &["script", "s.txt", "--keep-going"]).stdout, "track.new: {\"tracks\":[1]}\n");
|
||||
assert_eq!(invoke(&dir, &["script", "--keep-going", "s.txt"]).stdout, "track.new: {\"tracks\":[1]}\n");
|
||||
assert_fails(&invoke(&dir, &["script", "nope.txt"]), &format!("nope.txt: {}", not_found()));
|
||||
assert_fails(&invoke(&dir, &["script", "s.txt", "--in=nope.scraft"]), &format!("nope.scraft: {}", not_found()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_line_errors_stop_unless_keep_going() {
|
||||
let dir = sandbox("script-errors");
|
||||
std::fs::write(dir.join("bad.txt"), "nosuch.cmd {}\ntrack.new {}\n").unwrap();
|
||||
std::fs::write(dir.join("badjson.txt"), "track.new {x\n").unwrap();
|
||||
// A stopping line error carries the `error:` prefix; a --keep-going one is a bare line.
|
||||
let stop = invoke(&dir, &["script", "bad.txt"]);
|
||||
assert_eq!((stop.code, stop.stdout.as_str()), (1, ""));
|
||||
assert_eq!(stop.stderr, "error: bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
|
||||
let keep = invoke(&dir, &["script", "bad.txt", "--keep-going"]);
|
||||
assert_eq!((keep.code, keep.stdout.as_str()), (0, "track.new: {\"tracks\":[1]}\n"));
|
||||
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
|
||||
assert_fails(&invoke(&dir, &["script", "badjson.txt"]), "badjson.txt:1: bad JSON: key must be a string at line 1 column 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_prints_json_for_audio_and_fails_for_missing_files() {
|
||||
let dir = sandbox("info");
|
||||
let got = invoke(&dir, &["info", "in.wav"]);
|
||||
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
|
||||
assert!(
|
||||
got.stdout.contains("\"channels\": 1")
|
||||
&& got.stdout.contains("\"sample_rate\": 48000")
|
||||
&& got.stdout.contains("\"sample_format\": \"Int16\""),
|
||||
"{}",
|
||||
got.stdout
|
||||
);
|
||||
assert_fails(&invoke(&dir, &["info", "nope.wav"]), &format!("nope.wav: {}", not_found()));
|
||||
}
|
||||
|
||||
/// The extension decides a session in any case, as the desktop app's `open_path` does.
|
||||
#[test]
|
||||
fn info_reads_a_session_whatever_the_extension_case() {
|
||||
let dir = sandbox("info-session-case");
|
||||
assert_eq!(invoke(&dir, &["run", "--demo", "--save", "S.SCRAFT"]).code, 0);
|
||||
let got = invoke(&dir, &["info", "S.SCRAFT"]);
|
||||
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
|
||||
assert!(got.stdout.starts_with("SESSION NAME:"), "{}", got.stdout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_compact_is_the_same_json_on_one_line() {
|
||||
let dir = sandbox("info-compact");
|
||||
let (pretty, compact) = (invoke(&dir, &["info", "in.wav"]), invoke(&dir, &["info", "in.wav", "--compact"]));
|
||||
assert_eq!((compact.code, compact.stderr.as_str()), (0, ""));
|
||||
assert_eq!(compact.stdout.trim_end().lines().count(), 1, "{}", compact.stdout);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&pretty.stdout).unwrap(),
|
||||
serde_json::from_str::<serde_json::Value>(&compact.stdout).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_writes_a_report_with_write_in_both_spellings() {
|
||||
let dir = sandbox("parity");
|
||||
let stdout_report = invoke(&dir, &["parity"]);
|
||||
assert_eq!(stdout_report.code, 0);
|
||||
assert!(stdout_report.stdout.starts_with("# Parity report\n"), "{}", stdout_report.stdout);
|
||||
for (flag, name) in [(vec!["--write", "p1.md"], "p1.md"), (vec!["--write=p2.md"], "p2.md")] {
|
||||
let mut args = vec!["parity"];
|
||||
args.extend(flag);
|
||||
let got = invoke(&dir, &args);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert!(got.stdout.starts_with(&format!("wrote {name}: ")), "{}", got.stdout);
|
||||
assert!(std::fs::read_to_string(dir.join(name)).unwrap().starts_with("# Parity report\n"));
|
||||
}
|
||||
assert_usage_error(&invoke(&dir, &["parity", "--write"]), "a value is required for '--write <PATH>' but none was supplied");
|
||||
}
|
||||
|
||||
// ---- outputs that replace an input ----
|
||||
|
||||
/// `in.wav`, `s.txt` and a saved session `s.scraft`.
|
||||
fn sandbox_with_session(tag: &str) -> PathBuf {
|
||||
let dir = sandbox(tag);
|
||||
assert_eq!(invoke(&dir, &["run", "--save", "s.scraft"]).code, 0);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_output_that_is_an_input_is_refused_before_any_work() {
|
||||
let dir = sandbox_with_session("out-is-in");
|
||||
std::fs::hard_link(dir.join("in.wav"), dir.join("hard.wav")).unwrap();
|
||||
let (wav, session, script) =
|
||||
(std::fs::read(dir.join("in.wav")).unwrap(), std::fs::read(dir.join("s.scraft")).unwrap(), std::fs::read(dir.join("s.txt")).unwrap());
|
||||
let in_wav = dir.join("in.wav");
|
||||
let absolute = in_wav.to_str().unwrap();
|
||||
for out in ["in.wav", "./in.wav", "../{}/in.wav", absolute, "hard.wav"] {
|
||||
let out = out.replace("{}", dir.file_name().unwrap().to_str().unwrap());
|
||||
let got = invoke(&dir, &["convert", "in.wav", &out, "--rate", "44100"]);
|
||||
assert_usage_error(&got, &format!("<OUT> {out} is the <IN> file, which it would replace: choose another file, or pass --in-place"));
|
||||
}
|
||||
// Nothing ran: no --cmd result, no bounce, no save.
|
||||
let refused: &[(&[&str], &str)] = &[
|
||||
(&["run", "--in", "s.scraft", "--cmd", "track.new", "--save", "s.scraft"], "--save s.scraft is the --in session"),
|
||||
(&["run", "--in=./s.scraft", "--cmd", "track.new", "--bounce", "s.scraft"], "--bounce s.scraft is the --in session"),
|
||||
// --in-place lets only --save replace the session.
|
||||
(
|
||||
&["run", "--in", "s.scraft", "--in-place", "--cmd", "track.new", "--bounce", "./s.scraft"],
|
||||
"--bounce ./s.scraft is the --in session, which it would replace with audio: choose another file",
|
||||
),
|
||||
(&["script", "s.txt", "--in", "s.scraft", "--in-place", "--bounce", "s.scraft"], "--bounce s.scraft is the --in session"),
|
||||
(&["script", "s.txt", "--in", "s.scraft", "--save", "./s.scraft"], "--save ./s.scraft is the --in session"),
|
||||
(&["script", "s.txt", "--save", "./s.txt"], "--save ./s.txt is the script file, which it would replace: choose another file"),
|
||||
(&["script", "s.txt", "--in", "s.scraft", "--in-place", "--bounce", "s.txt"], "--bounce s.txt is the script file"),
|
||||
(&["run", "--demo", "--cmd", "track.new", "--bounce", "b.wav", "--save", "./b.wav"], "--bounce and --save both name ./b.wav"),
|
||||
];
|
||||
for (args, message) in refused {
|
||||
let got = invoke(&dir, args);
|
||||
assert_usage_error(&got, message);
|
||||
assert!(got.stderr.contains("\nUsage: soundcraft-cli "), "{}", got.stderr);
|
||||
}
|
||||
assert_eq!(std::fs::read(dir.join("in.wav")).unwrap(), wav);
|
||||
assert_eq!(std::fs::read(dir.join("s.scraft")).unwrap(), session);
|
||||
assert_eq!(std::fs::read(dir.join("s.txt")).unwrap(), script);
|
||||
assert!(!dir.join("b.wav").exists());
|
||||
assert_usage_error(
|
||||
&invoke(&dir, &["run", "--in-place", "--save", "s.scraft"]),
|
||||
"the following required arguments were not provided:\n --in <S.scraft>",
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_output_linked_to_the_input_is_refused() {
|
||||
let dir = sandbox_with_session("out-is-in-link");
|
||||
std::os::unix::fs::symlink("in.wav", dir.join("link.wav")).unwrap();
|
||||
std::os::unix::fs::symlink(".", dir.join("here")).unwrap();
|
||||
assert_usage_error(&invoke(&dir, &["convert", "in.wav", "link.wav"]), "<OUT> link.wav is the <IN> file");
|
||||
assert_usage_error(&invoke(&dir, &["run", "--in", "s.scraft", "--save", "here/s.scraft"]), "--save here/s.scraft is the --in session");
|
||||
// Two outputs that don't exist yet, one through a linked folder.
|
||||
assert_usage_error(&invoke(&dir, &["run", "--bounce", "here/b.wav", "--save", "b.wav"]), "--bounce and --save both name b.wav");
|
||||
assert_eq!(rate_of(&dir, "in.wav"), 48_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_place_replaces_the_input_and_other_files_are_still_replaced() {
|
||||
let dir = sandbox_with_session("in-place");
|
||||
let got = invoke(&dir, &["convert", "in.wav", "./in.wav", "--rate", "44100", "--in-place"]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert_eq!(rate_of(&dir, "in.wav"), 44_100);
|
||||
let got = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new", "--save", "s.scraft", "--in-place"]);
|
||||
assert_eq!(got.stdout, "track.new: {\"tracks\":[1]}\n{\"audio_files_written\":0,\"path\":\"s.scraft\"}\n", "{}", got.stderr);
|
||||
// The saved session holds that track, so the next one is the second.
|
||||
assert_eq!(invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new"]).stdout, "track.new: {\"tracks\":[2]}\n");
|
||||
// An existing file that is no input is replaced without --in-place.
|
||||
for rate in ["32000", "22050"] {
|
||||
let got = invoke(&dir, &["convert", "in.wav", "o.wav", "--rate", rate]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
}
|
||||
assert_eq!(rate_of(&dir, "o.wav"), 22_050);
|
||||
// other.scraft gets the script's track, then is replaced by s.scraft as it is.
|
||||
let next_track = |session: &str| invoke(&dir, &["run", "--in", session, "--cmd", "track.new"]).stdout;
|
||||
let got = invoke(&dir, &["script", "s.txt", "--in", "s.scraft", "--save", "other.scraft"]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert_eq!(next_track("other.scraft"), "track.new: {\"tracks\":[3]}\n");
|
||||
let got = invoke(&dir, &["run", "--in", "s.scraft", "--save", "other.scraft"]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert_eq!(next_track("other.scraft"), "track.new: {\"tracks\":[2]}\n");
|
||||
}
|
||||
|
||||
/// A write that fails leaves the input as it was: the new bytes go to a new file first.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_in_place_convert_keeps_the_input() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = sandbox("in-place-fails");
|
||||
let before = std::fs::read(dir.join("in.wav")).unwrap();
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
let can_create = std::fs::File::create(dir.join("probe")).is_ok();
|
||||
if !can_create {
|
||||
let got = invoke(&dir, &["convert", "in.wav", "in.wav", "--rate", "44100", "--in-place"]);
|
||||
assert_eq!(got.code, 1, "{}", got.stderr);
|
||||
assert_eq!(std::fs::read(dir.join("in.wav")).unwrap(), before);
|
||||
}
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(std::fs::read_dir(&dir).unwrap().all(|e| !e.unwrap().file_name().to_string_lossy().ends_with(".part")));
|
||||
}
|
||||
|
||||
/// An in-place output that is a link to the input replaces the input, and the link stays a link.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn in_place_through_a_link_replaces_the_linked_input() {
|
||||
let dir = sandbox_with_session("in-place-link");
|
||||
std::os::unix::fs::symlink("in.wav", dir.join("link.wav")).unwrap();
|
||||
std::os::unix::fs::symlink("s.scraft", dir.join("link.scraft")).unwrap();
|
||||
let got = invoke(&dir, &["convert", "in.wav", "link.wav", "--rate", "44100", "--in-place"]);
|
||||
assert_eq!(got.stdout, "wrote link.wav (1 ch, 44100 Hz, 0.00 s)\n", "{}", got.stderr);
|
||||
assert_eq!(rate_of(&dir, "in.wav"), 44_100);
|
||||
let got = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new", "--save", "link.scraft", "--in-place"]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
// s.scraft holds that track, so the next one is the second.
|
||||
assert_eq!(invoke(&dir, &["run", "--in", "s.scraft", "--cmd", "track.new"]).stdout, "track.new: {\"tracks\":[2]}\n");
|
||||
for link in ["link.wav", "link.scraft"] {
|
||||
assert!(dir.join(link).symlink_metadata().unwrap().file_type().is_symlink(), "{link}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- app ----
|
||||
|
||||
#[test]
|
||||
fn app_port_comes_from_the_flag_then_the_environment() {
|
||||
let dir = sandbox("app-env");
|
||||
assert_refused(&invoke_env(&dir, &["app", "session.inspect"], Some("1")), 1);
|
||||
assert_refused(&invoke_env(&dir, &["app", "--port", "2", "session.inspect"], Some("1")), 2);
|
||||
// The flag wins before the variable is read, so a bad one doesn't matter.
|
||||
assert_refused(&invoke_env(&dir, &["app", "--port", "2", "session.inspect"], Some("abc")), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_takes_an_empty_port_variable_as_unset() {
|
||||
if std::net::TcpStream::connect("127.0.0.1:7979").is_ok() {
|
||||
eprintln!("skipped: something listens on 127.0.0.1:7979, the default port");
|
||||
return;
|
||||
}
|
||||
let dir = sandbox("app-env-empty");
|
||||
for empty in ["", " "] {
|
||||
assert_refused(&invoke_env(&dir, &["app", "session.inspect"], Some(empty)), 7979);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_port_variable_is_a_usage_error_naming_it() {
|
||||
let dir = sandbox("app-env-bad");
|
||||
for bad in ["abc", "70000", ":80"] {
|
||||
assert_usage_error(
|
||||
&invoke_env(&dir, &["app", "session.inspect"], Some(bad)),
|
||||
&format!("invalid value '{bad}' for 'SOUNDCRAFT_CONTROL_PORT': expected a port, 0-65535, or host:port"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_help_names_the_port_variable() {
|
||||
let got = invoke(&sandbox("app-help"), &["app", "--help"]);
|
||||
assert_eq!(got.code, 0, "{}", got.stderr);
|
||||
assert!(got.stdout.contains("[env: SOUNDCRAFT_CONTROL_PORT=]\n [default: 7979]\n"), "{}", got.stdout);
|
||||
}
|
||||
|
||||
// ---- mcp ----
|
||||
|
||||
#[test]
|
||||
fn mcp_accepts_its_flags_in_both_spellings_and_exits_cleanly_on_empty_stdin() {
|
||||
let dir = sandbox("mcp-ok");
|
||||
for args in [vec!["mcp"], vec!["mcp", "--demo"], vec!["mcp", "--connect", "1"], vec!["mcp", "--connect=1"]] {
|
||||
assert_silent_success(&invoke(&dir, &args));
|
||||
}
|
||||
// A root prints the confinement line to stderr (never stdout).
|
||||
for args in [&["mcp", "--automation-read-root", "."][..], &["mcp", "--automation-read-root=."][..]] {
|
||||
let got = invoke(&dir, args);
|
||||
assert_eq!((got.code, got.stdout.as_str()), (0, ""));
|
||||
assert!(got.stderr.starts_with("soundcraft-cli: files confined: read ") && got.stderr.ends_with("; write none\n"), "{}", got.stderr);
|
||||
}
|
||||
// A repeated write-root flag is accepted in either spelling.
|
||||
let twice = invoke(&dir, &["mcp", "--automation-write-root=.", "--automation-write-root", "."]);
|
||||
assert_eq!(twice.code, 0);
|
||||
assert!(
|
||||
twice.stderr.starts_with("soundcraft-cli: files confined: read none; write ") && !twice.stderr.contains("; write none"),
|
||||
"{}",
|
||||
twice.stderr
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_demo_with_connect_is_a_usage_error() {
|
||||
let dir = sandbox("mcp-demo-connect");
|
||||
assert_usage_error(&invoke(&dir, &["mcp", "--demo", "--connect", "1"]), "the argument '--demo' cannot be used with '--connect <PORT>'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_roots_with_connect_are_a_usage_error() {
|
||||
let dir = sandbox("mcp-roots-connect");
|
||||
// Refused before any folder is opened, so a missing one isn't reported.
|
||||
assert_usage_error(
|
||||
&invoke(&dir, &["mcp", "--connect", "1", "--automation-write-root", "missing"]),
|
||||
"the argument '--connect <PORT>' cannot be used with '--automation-write-root <DIR>'",
|
||||
);
|
||||
assert_usage_error(
|
||||
&invoke(&dir, &["mcp", "--automation-read-root=.", "--connect=1"]),
|
||||
"the argument '--automation-read-root <DIR>' cannot be used with '--connect <PORT>'",
|
||||
);
|
||||
}
|
||||
|
||||
/// A name that isn't valid Unicode works wherever the tool itself reads or writes the file
|
||||
/// (`convert`); a session or bounce the engine opens or writes by text name is refused (exit 1),
|
||||
/// naming the file. Run in-process: the names need no shell.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_file_names() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let dir = sandbox("nonunicode");
|
||||
let latin1 = |ext: &str| {
|
||||
let mut n = b"caf\xe9.".to_vec();
|
||||
n.extend_from_slice(ext.as_bytes());
|
||||
dir.join(OsString::from_vec(n))
|
||||
};
|
||||
let run = |args: Vec<OsString>| {
|
||||
let (mut out, mut err) = (Vec::new(), Vec::new());
|
||||
let code = soundcraft_cli::run(args, &mut out, &mut err);
|
||||
(code, String::from_utf8_lossy(&err).into_owned())
|
||||
};
|
||||
let (code, err) = run(vec!["convert".into(), dir.join("in.wav").into(), latin1("flac").into()]);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
assert!(std::fs::metadata(latin1("flac")).unwrap().len() > 0);
|
||||
// A session opens by path; only paths that go into the engine's JSON must be Unicode.
|
||||
let (code, err) = run(vec!["run".into(), "--demo".into(), "--save".into(), dir.join("s.scraft").into()]);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
std::fs::rename(dir.join("s.scraft"), latin1("scraft")).unwrap();
|
||||
let (code, err) = run(vec!["info".into(), latin1("scraft").into()]);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
let (code, err) = run(vec!["run".into(), "--demo".into(), "--bounce".into(), latin1("wav").into()]);
|
||||
assert_eq!(code, 1);
|
||||
assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -209,13 +209,13 @@ fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
// #874: a flag may be given again, but every folder it names must exist.
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root"], "needs a folder");
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--demo"], "needs a folder");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root"], "a value is required");
|
||||
fails(&["--automation-read-root"], "a value is required");
|
||||
fails(&["--automation-write-root", "--demo"], "a value is required");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root="], "needs a non-empty folder");
|
||||
fails(&["--automation-write-root="], "a value is required");
|
||||
// A misspelled root flag must not start an unconfined server.
|
||||
fails(&["--automation-read-roots", dir], "unknown mcp option");
|
||||
fails(&["--automation-read-roots", dir], "unexpected argument '--automation-read-roots'");
|
||||
// A file is not a folder.
|
||||
std::fs::write(base.join("f.txt"), "x").unwrap();
|
||||
fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root");
|
||||
@@ -224,9 +224,9 @@ fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
|
||||
#[test]
|
||||
fn usage_lists_the_root_flags() {
|
||||
let o = run(&["--help"]);
|
||||
let o = run(&["mcp", "--help"]);
|
||||
let help = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||
assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"), "{help}");
|
||||
assert!(help.contains("--automation-read-root <DIR>") && help.contains("--automation-write-root <DIR>"), "{help}");
|
||||
}
|
||||
|
||||
/// Without `--start`/`--end`, `--bounce` covers the whole session, not a stray edit selection
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod behaviour;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod cli;
|
||||
mod closed_stdout;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod man_and_completions;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod run;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The clap definition is consistent, and every shell's completions and every man page
|
||||
//! (`examples/man-and-completions.rs`) can be generated from it.
|
||||
|
||||
use clap::ValueEnum;
|
||||
use clap_complete::Shell;
|
||||
|
||||
// The example itself, so the test writes the files exactly as the packages get them.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../../examples/man-and-completions.rs"]
|
||||
mod example;
|
||||
|
||||
#[test]
|
||||
fn command_is_consistent() {
|
||||
soundcraft_cli::command().debug_assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completions_generate_for_every_shell() {
|
||||
for &shell in Shell::value_variants() {
|
||||
let mut out = Vec::new();
|
||||
clap_complete::generate(shell, &mut soundcraft_cli::command(), "soundcraft-cli", &mut out);
|
||||
assert!(!out.is_empty(), "{shell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn man_pages_render_for_the_command_and_every_subcommand() {
|
||||
let cmd = soundcraft_cli::command();
|
||||
for page in std::iter::once(&cmd).chain(cmd.get_subcommands()) {
|
||||
let mut out = Vec::new();
|
||||
clap_mangen::Man::new(page.clone()).render(&mut out).unwrap();
|
||||
assert!(!out.is_empty(), "{}", page.get_name());
|
||||
}
|
||||
}
|
||||
|
||||
/// The example writes the five completion files under the names `packaging/linux/package.sh` and
|
||||
/// `packaging/freebsd/package.sh` install, `soundcraft-cli.1` and a page per subcommand, and no
|
||||
/// pages for the `help` subcommand.
|
||||
#[test]
|
||||
fn the_example_writes_the_files_the_packages_install() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-{}-man-and-completions", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
example::generate(&dir).unwrap();
|
||||
|
||||
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||||
written.sort();
|
||||
let completions = ["soundcraft-cli.bash", "_soundcraft-cli", "soundcraft-cli.fish", "soundcraft-cli.elv", "_soundcraft-cli.ps1"];
|
||||
let subcommands = ["info", "convert", "run", "script", "commands", "describe", "app", "mcp", "parity", "plugins"];
|
||||
let mut expected: Vec<String> = completions.map(String::from).into();
|
||||
expected.push("soundcraft-cli.1".into());
|
||||
expected.extend(subcommands.iter().map(|s| format!("soundcraft-cli-{s}.1")));
|
||||
expected.sort();
|
||||
assert_eq!(written, expected);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! The CLI run in-process through `soundcraft_cli::run`: the tests that need no files, working
|
||||
//! directory, environment or standard input. Those that do run the real binary (`behaviour.rs`).
|
||||
//! A change to the command line's behaviour updates them on purpose.
|
||||
|
||||
use soundcraft_cli::run;
|
||||
|
||||
/// What [`run`] returned and wrote.
|
||||
struct Invoke {
|
||||
code: i32,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
fn invoke(args: &[&str]) -> Invoke {
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
let code = run(args.iter().copied(), &mut stdout, &mut stderr);
|
||||
Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() }
|
||||
}
|
||||
|
||||
/// Exit 1, nothing on stdout, and exactly `error: {message}` on stderr.
|
||||
#[track_caller]
|
||||
fn assert_fails(got: &Invoke, message: &str) {
|
||||
assert_eq!(got.code, 1, "stderr: {}", got.stderr);
|
||||
assert_eq!(got.stdout, "");
|
||||
assert_eq!(got.stderr, format!("error: {message}\n"));
|
||||
}
|
||||
|
||||
/// Exit 0 with nothing on either stream.
|
||||
#[track_caller]
|
||||
fn assert_silent_success(got: &Invoke) {
|
||||
assert_eq!((got.code, got.stdout.as_str(), got.stderr.as_str()), (0, "", ""));
|
||||
}
|
||||
|
||||
/// Exit 2, nothing on stdout, and a clap usage error on stderr: `error: ` with `message`, ending
|
||||
/// with the `--help` hint.
|
||||
#[track_caller]
|
||||
fn assert_usage_error(got: &Invoke, message: &str) {
|
||||
assert_eq!(got.code, 2, "stderr: {}", got.stderr);
|
||||
assert_eq!(got.stdout, "");
|
||||
assert!(got.stderr.starts_with(&format!("error: {message}")), "{}", got.stderr);
|
||||
assert!(got.stderr.ends_with("\n\nFor more information, try '--help'.\n"), "{}", got.stderr);
|
||||
}
|
||||
|
||||
/// Exit 0, help on stdout, nothing on stderr.
|
||||
#[track_caller]
|
||||
fn assert_help(got: &Invoke) -> &str {
|
||||
assert_eq!((got.code, got.stderr.as_str()), (0, ""), "{}", got.stdout);
|
||||
&got.stdout
|
||||
}
|
||||
|
||||
/// The OS's message for a missing file: error 2 is `ENOENT` on Unix and `ERROR_FILE_NOT_FOUND` on Windows.
|
||||
fn not_found() -> std::io::Error {
|
||||
std::io::Error::from_raw_os_error(2)
|
||||
}
|
||||
|
||||
/// Exit 1 with the refused-connection message for `port`; the OS's text for the refusal differs by platform.
|
||||
#[track_caller]
|
||||
fn assert_refused(got: &Invoke, port: u16) {
|
||||
assert_eq!((got.code, got.stdout.as_str()), (1, ""));
|
||||
let prefix = format!("error: cannot reach SoundCraft at 127.0.0.1:{port}: ");
|
||||
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_help_lists_every_subcommand_on_stdout() {
|
||||
let long = invoke(&["--help"]);
|
||||
let help = assert_help(&long);
|
||||
assert!(help.starts_with("soundcraft-cli: SoundCraft without a window\n\nUsage: soundcraft-cli <COMMAND>\n"), "{help}");
|
||||
for sub in ["info", "convert", "run", "script", "commands", "describe", "app", "mcp", "parity", "plugins", "help"] {
|
||||
assert!(help.contains(&format!("\n {sub} ")), "{sub}: {help}");
|
||||
}
|
||||
assert!(help.contains("-V, --version") && !help.contains("\n version "), "{help}");
|
||||
for spelling in ["-h", "help"] {
|
||||
let got = invoke(&[spelling]);
|
||||
assert_eq!((got.code, got.stdout, got.stderr), (0, long.stdout.clone(), String::new()), "{spelling}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_flags_print_the_long_version_to_stdout() {
|
||||
let expected = format!("soundcraft-cli {} ({} {})\n", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
for spelling in ["--version", "-V"] {
|
||||
let got = invoke(&[spelling]);
|
||||
assert_eq!((got.code, got.stdout, got.stderr), (0, expected.clone(), String::new()), "{spelling}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_is_not_a_subcommand() {
|
||||
assert_usage_error(&invoke(&["version"]), "unrecognized subcommand 'version'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_subcommand_shows_that_subcommands_help() {
|
||||
let got = invoke(&["help", "convert"]);
|
||||
let help = assert_help(&got);
|
||||
assert!(help.starts_with("Convert an audio file"), "{help}");
|
||||
assert_eq!(help, assert_help(&invoke(&["convert", "--help"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_names_every_format_the_command_reads_and_writes() {
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
let read = format!("({})", FileFormat::readable_extensions().join(", "));
|
||||
let write = format!("its extension picks the format ({})", FileFormat::writable_extensions().join(", "));
|
||||
let convert = invoke(&["convert", "--help"]);
|
||||
let convert = assert_help(&convert);
|
||||
assert!(convert.contains(&read) && convert.contains(&write), "{convert}");
|
||||
let info = invoke(&["info", "--help"]);
|
||||
assert!(assert_help(&info).contains(&read), "{}", info.stdout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_arguments_prints_the_help_to_stderr_and_exits_2() {
|
||||
let got = invoke(&[]);
|
||||
assert_eq!((got.code, got.stdout.as_str()), (2, ""));
|
||||
assert_eq!(got.stderr, invoke(&["--help"]).stdout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_subcommand_is_a_usage_error() {
|
||||
assert_usage_error(&invoke(&["bogus"]), "unrecognized subcommand 'bogus'");
|
||||
assert_usage_error(&invoke(&["bogus", "--help"]), "unrecognized subcommand 'bogus'");
|
||||
// Subcommand names are case sensitive and not abbreviated.
|
||||
assert_usage_error(&invoke(&["INFO"]), "unrecognized subcommand 'INFO'");
|
||||
assert!(invoke(&["plug"]).stderr.contains("tip: a similar subcommand exists: 'plugins'"));
|
||||
assert_usage_error(&invoke(&["--bogus"]), "unexpected argument '--bogus' found");
|
||||
}
|
||||
|
||||
/// Each subcommand's help: its usage line, then every argument and flag it takes.
|
||||
#[test]
|
||||
fn every_subcommand_has_help_listing_its_arguments() {
|
||||
let engine = [
|
||||
"--in <S.scraft>",
|
||||
"--demo",
|
||||
"--sample",
|
||||
"--bounce <OUT>",
|
||||
"--start <T>",
|
||||
"--end <T>",
|
||||
"--save <S.scraft>",
|
||||
"--inspect",
|
||||
"--compact",
|
||||
"--in-place",
|
||||
];
|
||||
let cases: &[(&str, &str, &[&str])] = &[
|
||||
("info", "info [OPTIONS] <FILE>", &["<FILE>", "--compact"]),
|
||||
(
|
||||
"convert",
|
||||
"convert [OPTIONS] <IN> <OUT>",
|
||||
&["<IN>", "<OUT>", "--bit-depth <16|24|32f>", "--rate <HZ>", "--normalize", "--dither", "--no-dither", "--in-place"],
|
||||
),
|
||||
("run", "run [OPTIONS]", &["--cmd <ID[=JSON]>"]),
|
||||
("script", "script [OPTIONS] <FILE>", &["<FILE>", "--keep-going"]),
|
||||
("commands", "commands [FILTER]", &["[FILTER]"]),
|
||||
("describe", "describe [OPTIONS] <ID>", &["<ID>", "--compact"]),
|
||||
(
|
||||
"app",
|
||||
"app [OPTIONS] <METHOD> [JSON]",
|
||||
&["--port <P>", "--compact", "<METHOD>", "[JSON]", "[env: SOUNDCRAFT_CONTROL_PORT", "[default: 7979]"],
|
||||
),
|
||||
(
|
||||
"mcp",
|
||||
"mcp [OPTIONS]",
|
||||
&["--connect <PORT>", "--demo", "--automation-read-root <DIR>", "--automation-write-root <DIR>", "conflict with --connect"],
|
||||
),
|
||||
("parity", "parity [OPTIONS]", &["--write <PATH>"]),
|
||||
("plugins", "plugins", &[]),
|
||||
];
|
||||
for &(sub, usage, args) in cases {
|
||||
let long = invoke(&[sub, "--help"]);
|
||||
let help = assert_help(&long);
|
||||
assert!(help.contains(&format!("\nUsage: soundcraft-cli {usage}\n")), "{sub}: {help}");
|
||||
let shared: &[&str] = if matches!(sub, "run" | "script") { &engine } else { &[] };
|
||||
for arg in args.iter().chain(shared).chain(&["-h, --help"]) {
|
||||
assert!(help.contains(arg), "{sub} lacks {arg}: {help}");
|
||||
}
|
||||
// `help SUB` is `SUB --help`; `-h` is the one-line-per-flag summary.
|
||||
assert_eq!(invoke(&["help", sub]).stdout, long.stdout, "help {sub}");
|
||||
assert!(assert_help(&invoke(&[sub, "-h"])).contains(&format!("\nUsage: soundcraft-cli {usage}\n")), "{sub} -h");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_wins_over_a_mistake_before_it() {
|
||||
let help = invoke(&["run", "--help"]).stdout;
|
||||
assert_eq!(assert_help(&invoke(&["run", "--bounce", "--help"])), help);
|
||||
assert_eq!(assert_help(&invoke(&["run", "--bogus", "--help"])), help);
|
||||
assert!(assert_help(&invoke(&["convert", "--rate", "-h"])).contains("Usage: soundcraft-cli convert"));
|
||||
assert!(assert_help(&invoke(&["mcp", "--connect", "--help"])).contains("Usage: soundcraft-cli mcp"));
|
||||
// After `--`, `--help` is a value.
|
||||
assert_eq!(invoke(&["info", "--", "--help"]).code, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_and_script_value_flags_without_a_value_are_usage_errors() {
|
||||
for (flag, name) in [("--cmd", "ID[=JSON]"), ("--in", "S.scraft"), ("--save", "S.scraft"), ("--bounce", "OUT"), ("--start", "T"), ("--end", "T")]
|
||||
{
|
||||
let message = format!("a value is required for '{flag} <{name}>' but none was supplied");
|
||||
assert_usage_error(&invoke(&["run", flag]), &message);
|
||||
if flag != "--cmd" {
|
||||
assert_usage_error(&invoke(&["script", "s.txt", flag]), &message);
|
||||
}
|
||||
}
|
||||
// A following flag is not the value.
|
||||
assert_usage_error(&invoke(&["run", "--bounce", "--inspect"]), "a value is required for '--bounce <OUT>'");
|
||||
assert_usage_error(&invoke(&["script", "s.txt", "--demo", "--bounce"]), "a value is required for '--bounce <OUT>'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_flags_reject_an_inline_value() {
|
||||
assert_usage_error(&invoke(&["run", "--demo=1"]), "unexpected value '1' for '--demo' found; no more were expected");
|
||||
assert_usage_error(&invoke(&["run", "--inspect=1"]), "unexpected value '1' for '--inspect' found; no more were expected");
|
||||
assert_usage_error(&invoke(&["script", "s.txt", "--keep-going=1"]), "unexpected value '1' for '--keep-going' found; no more were expected");
|
||||
assert_usage_error(&invoke(&["mcp", "--demo=1"]), "unexpected value '1' for '--demo' found; no more were expected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_extra_positionals_are_usage_errors() {
|
||||
assert_usage_error(&invoke(&["info"]), "the following required arguments were not provided:\n <FILE>");
|
||||
assert_usage_error(&invoke(&["convert", "in.wav"]), "the following required arguments were not provided:\n <OUT>");
|
||||
assert_usage_error(&invoke(&["script"]), "the following required arguments were not provided:\n <FILE>");
|
||||
assert_usage_error(&invoke(&["describe"]), "the following required arguments were not provided:\n <ID>");
|
||||
assert_usage_error(&invoke(&["app", "--port", "1"]), "the following required arguments were not provided:\n <METHOD>");
|
||||
for args in [
|
||||
&["info", "a", "b"][..],
|
||||
&["convert", "in.wav", "out.wav", "b"],
|
||||
&["script", "s.txt", "b"],
|
||||
&["run", "b"],
|
||||
&["commands", "bounce", "b"],
|
||||
&["describe", "file.bounce_mix", "b"],
|
||||
&["app", "--port", "1", "session.inspect", "{}", "b"],
|
||||
&["mcp", "b"],
|
||||
&["parity", "b"],
|
||||
&["plugins", "b"],
|
||||
] {
|
||||
assert_usage_error(&invoke(args), "unexpected argument 'b' found");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_are_usage_errors_on_every_subcommand() {
|
||||
for args in [
|
||||
&["info", "in.wav", "--bogus"][..],
|
||||
&["convert", "in.wav", "out.wav", "--bogus"],
|
||||
&["run", "--bogus"],
|
||||
&["script", "s.txt", "--bogus"],
|
||||
&["commands", "--bogus"],
|
||||
&["describe", "--bogus"],
|
||||
&["app", "--bogus", "session.inspect"],
|
||||
&["mcp", "--bogus"],
|
||||
&["parity", "--bogus"],
|
||||
&["plugins", "--bogus"],
|
||||
] {
|
||||
assert_usage_error(&invoke(args), "unexpected argument '--bogus' found");
|
||||
}
|
||||
// A single-dash word is a short flag, and there are none but -h.
|
||||
assert_usage_error(&invoke(&["run", "-x"]), "unexpected argument '-x' found");
|
||||
assert_usage_error(&invoke(&["mcp", "--automation-bogus=x"]), "unexpected argument '--automation-bogus' found");
|
||||
}
|
||||
|
||||
/// `args_os`: text that isn't valid Unicode reaches clap instead of aborting the process.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
let code = run([OsString::from("describe"), OsString::from_vec(b"\xe9.wav".to_vec())], &mut stdout, &mut stderr);
|
||||
assert_eq!((code, stdout.as_slice()), (2, &b""[..]));
|
||||
assert!(String::from_utf8_lossy(&stderr).starts_with("error: invalid UTF-8 was detected in one or more arguments"));
|
||||
}
|
||||
|
||||
/// A bounce or save path goes to the engine as JSON text: a non-Unicode one fails before any command runs.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_non_unicode_output_fails_before_the_commands_run() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
for flag in ["--bounce", "--save"] {
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
let args = ["run", "--cmd", "track.new", flag].map(OsString::from);
|
||||
let code = run(args.into_iter().chain([OsString::from_vec(b"\xe9.out".to_vec())]), &mut stdout, &mut stderr);
|
||||
assert_eq!((code, stdout.as_slice()), (1, &b""[..]), "{flag}: {}", String::from_utf8_lossy(&stderr));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_demo_and_sample_are_the_same_flag() {
|
||||
let demo = invoke(&["run", "--demo", "--inspect"]);
|
||||
let sample = invoke(&["run", "--sample", "--inspect"]);
|
||||
assert_eq!((demo.code, sample.code), (0, 0));
|
||||
assert_eq!(demo.stdout, sample.stdout);
|
||||
assert!(demo.stdout.contains("\"name\": \"Verb\""), "{}", demo.stdout);
|
||||
}
|
||||
|
||||
/// `--compact` prints the same JSON on one line.
|
||||
#[track_caller]
|
||||
fn assert_compact_matches_pretty(pretty: &[&str], compact: &[&str]) {
|
||||
let (p, c) = (invoke(pretty), invoke(compact));
|
||||
assert_eq!((p.code, c.code), (0, 0), "{} {}", p.stderr, c.stderr);
|
||||
assert!(p.stdout.trim_end().contains('\n'), "pretty form spans lines: {}", p.stdout);
|
||||
assert_eq!(c.stdout.trim_end().lines().count(), 1, "{}", c.stdout);
|
||||
assert!(c.stdout.ends_with('\n'));
|
||||
let (p, c): (serde_json::Value, serde_json::Value) = (serde_json::from_str(&p.stdout).unwrap(), serde_json::from_str(&c.stdout).unwrap());
|
||||
assert_eq!(p, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_prints_describe_and_run_inspect_on_one_line() {
|
||||
assert_compact_matches_pretty(&["describe", "file.bounce_mix"], &["describe", "file.bounce_mix", "--compact"]);
|
||||
assert_compact_matches_pretty(&["run", "--demo", "--inspect"], &["run", "--demo", "--inspect", "--compact"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_without_inspect_is_a_usage_error() {
|
||||
assert_usage_error(&invoke(&["run", "--compact"]), "the following required arguments were not provided:\n --inspect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_command_failures() {
|
||||
assert_fails(&invoke(&["run", "--cmd", "nosuch.cmd"]), "nosuch.cmd: unknown command `nosuch.cmd`");
|
||||
assert_fails(&invoke(&["run", "--in", "nope.scraft"]), &format!("nope.scraft: {}", not_found()));
|
||||
assert_fails(&invoke(&["run", "--in=nope.scraft"]), &format!("nope.scraft: {}", not_found()));
|
||||
}
|
||||
|
||||
/// Relations and typed values are checked by clap: exit 2, the message names the flag, and nothing runs.
|
||||
#[test]
|
||||
fn flag_relations_and_typed_values_are_usage_errors() {
|
||||
for (args, message) in [
|
||||
(&["convert", "in.wav", "o.wav", "--dither", "--no-dither"][..], "the argument '--dither' cannot be used with '--no-dither'"),
|
||||
(&["convert", "in.wav", "o.wav", "--rate", "0"], "invalid value '0' for '--rate <HZ>'"),
|
||||
(&["convert", "in.wav", "o.wav", "--rate=abc"], "invalid value 'abc' for '--rate <HZ>'"),
|
||||
(&["convert", "in.wav", "o.wav", "--bit-depth", "8"], "invalid value '8' for '--bit-depth <16|24|32f>'"),
|
||||
(&["run", "--in", "s.scraft", "--demo"], "the argument '--in <S.scraft>' cannot be used with '--demo'"),
|
||||
(&["script", "s.txt", "--in", "s.scraft", "--sample"], "the argument '--in <S.scraft>' cannot be used with '--demo'"),
|
||||
(&["run", "--start", "1"], "the following required arguments were not provided:\n --bounce <OUT>"),
|
||||
(&["script", "s.txt", "--end", "1"], "the following required arguments were not provided:\n --bounce <OUT>"),
|
||||
(&["run", "--bounce", "b.wav", "--start", ""], "invalid value '' for '--start <T>'"),
|
||||
(&["run", "--bounce", "b.wav", "--start", "nan"], "invalid value 'nan' for '--start <T>': expected a finite number"),
|
||||
(&["run", "--bounce", "b.wav", "--end", "inf"], "invalid value 'inf' for '--end <T>': expected a finite number"),
|
||||
(&["run", "--bounce", "b.wav", "--end", "1e999"], "invalid value '1e999' for '--end <T>': expected a finite number"),
|
||||
(&["run", "--cmd", "track.new={", "--bounce", "b.wav"], "invalid value 'track.new={' for '--cmd <ID[=JSON]>': bad JSON:"),
|
||||
(&["run", "--cmd", "=1"], "invalid value '=1' for '--cmd <ID[=JSON]>'"),
|
||||
(&["describe", ""], "a value is required for '<ID>'"),
|
||||
(&["commands", ""], "a value is required for '[FILTER]'"),
|
||||
(&["app", ""], "a value is required for '<METHOD>'"),
|
||||
] {
|
||||
let got = invoke(args);
|
||||
assert_usage_error(&got, message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_later_cmd_runs_none_of_them() {
|
||||
assert_usage_error(&invoke(&["run", "--cmd", "track.new", "--cmd", "track.new={"]), "invalid value 'track.new={'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_filters_by_id_or_label() {
|
||||
let all = invoke(&["commands"]);
|
||||
assert_eq!((all.code, all.stderr.as_str()), (0, ""));
|
||||
assert!(all.stdout.starts_with("session.new "), "{}", all.stdout);
|
||||
let bounce = invoke(&["commands", "bounce"]);
|
||||
assert!(bounce.stdout.contains("file.bounce_mix ") && !bounce.stdout.contains("session.new"), "{}", bounce.stdout);
|
||||
// Matching is case-insensitive on the label only: an upper-case filter finds the labels, not the ids.
|
||||
assert!(invoke(&["commands", "BOUNCE"]).stdout.contains("Bounce Mix..."));
|
||||
assert_silent_success(&invoke(&["commands", "zzzzqq"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_prints_json_for_a_known_id_and_fails_for_an_unknown_one() {
|
||||
let got = invoke(&["describe", "file.bounce_mix"]);
|
||||
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
|
||||
assert!(got.stdout.starts_with("{\n \"id\": \"file.bounce_mix\",\n \"label\": \"Bounce Mix...\""), "{}", got.stdout);
|
||||
assert_fails(&invoke(&["describe", "nosuch"]), "unknown command `nosuch`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_lists_one_line_per_plugin() {
|
||||
let got = invoke(&["plugins"]);
|
||||
assert_eq!((got.code, got.stderr.as_str()), (0, ""));
|
||||
assert!(got.stdout.starts_with("eq_1band EQ 1-Band Eq\n"), "{}", got.stdout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_reports_a_refused_connection_and_checks_arguments_first() {
|
||||
for args in [
|
||||
vec!["app", "--port", "1", "session.inspect"],
|
||||
vec!["app", "--port=1", "session.inspect"],
|
||||
vec!["app", "session.inspect", "--port", "1"],
|
||||
vec!["app", "--port", "1", "session.inspect", "{}"],
|
||||
] {
|
||||
assert_refused(&invoke(&args), 1);
|
||||
}
|
||||
assert_usage_error(
|
||||
&invoke(&["app", "--port", "1", "session.inspect", "{bad"]),
|
||||
"invalid value '{bad' for '[JSON]': bad JSON: key must be a string at line 1 column 2",
|
||||
);
|
||||
for bad in ["abc", "", "70000", ":80", "host:", "host:abc"] {
|
||||
assert_usage_error(
|
||||
&invoke(&["app", "--port", bad, "session.inspect"]),
|
||||
&format!("invalid value '{bad}' for '--port <P>': expected a port, 0-65535, or host:port"),
|
||||
);
|
||||
assert_usage_error(&invoke(&["mcp", "--connect", bad]), &format!("invalid value '{bad}' for '--connect <PORT>': expected a port"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_argument_errors_fail_before_reading_stdin() {
|
||||
assert_usage_error(&invoke(&["mcp", "--connect"]), "a value is required for '--connect <PORT>' but none was supplied");
|
||||
assert_usage_error(&invoke(&["mcp", "--connect", "--demo"]), "a value is required for '--connect <PORT>' but none was supplied");
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
let message = format!("a value is required for '{flag} <DIR>' but none was supplied");
|
||||
assert_usage_error(&invoke(&["mcp", flag]), &message);
|
||||
assert_usage_error(&invoke(&["mcp", flag, "--demo"]), &message);
|
||||
assert_usage_error(&invoke(&["mcp", &format!("{flag}=")]), &message);
|
||||
}
|
||||
assert_fails(
|
||||
&invoke(&["mcp", "--automation-read-root", "missing-dir"]),
|
||||
&format!("cannot open the automation read root `missing-dir`: {}", not_found()),
|
||||
);
|
||||
assert_usage_error(
|
||||
&invoke(&["mcp", "--automation-read-root", ".", "--connect", "1"]),
|
||||
"the argument '--automation-read-root <DIR>' cannot be used with '--connect <PORT>'",
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,11 @@ fn convert_rejects_an_unknown_option_instead_of_writing_the_file() {
|
||||
for bad in ["--bitdepth", "--normalise", "--bogus"] {
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
|
||||
assert!(!o.status.success(), "{bad} should fail");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("unknown option {bad}")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&o.stderr).contains(&format!("unexpected argument '{bad}' found")),
|
||||
"{bad}: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
assert!(!out.exists(), "{bad} should not write the output");
|
||||
}
|
||||
}
|
||||
@@ -71,7 +75,11 @@ fn convert_rejects_an_option_whose_value_is_missing() {
|
||||
for bad in ["--rate", "--bit-depth"] {
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
|
||||
assert!(!o.status.success(), "{bad} should fail");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("{bad} needs a value")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&o.stderr).contains(&format!("a value is required for '{bad} ")),
|
||||
"{bad}: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
assert!(!out.exists(), "{bad} should not write the output");
|
||||
}
|
||||
}
|
||||
@@ -104,11 +112,11 @@ fn run_rejects_a_misspelled_or_value_less_bounce() {
|
||||
let out = dir.join("x.wav");
|
||||
let o = run(&["run", "--cmd", "session.new", "--bounce"]);
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--bounce needs a value"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("a value is required for '--bounce <OUT>'"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
|
||||
let o = run(&["run", "--cmd", "session.new", "--bounces", out.to_str().unwrap()]);
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown option --bounces"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unexpected argument '--bounces' found"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "nothing should be bounced");
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ path = "src/main.rs"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
soundcraft-engine = { workspace = true }
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-ui-egui.workspace = true
|
||||
soundcraft-playback = { workspace = true }
|
||||
soundcraft-model = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The browser shell: the eframe web runner around [`SoundApp`].
|
||||
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
use soundcraft_engine::{Engine, io};
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_ui_egui::sampler_window::SampleRead;
|
||||
@@ -67,8 +68,6 @@ enum Purpose {
|
||||
}
|
||||
|
||||
const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION];
|
||||
const AUDIO_EXTS: &[&str] =
|
||||
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"];
|
||||
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
|
||||
|
||||
impl Purpose {
|
||||
@@ -94,11 +93,11 @@ impl Purpose {
|
||||
}
|
||||
}
|
||||
|
||||
fn filter(self) -> (&'static str, &'static [&'static str]) {
|
||||
fn filter(self) -> (&'static str, Vec<&'static str>) {
|
||||
match self {
|
||||
Self::Session => ("SoundCraft Session", SESSION_EXTS),
|
||||
Self::Audio => ("Audio", AUDIO_EXTS),
|
||||
Self::Midi => ("MIDI", MIDI_EXTS),
|
||||
Self::Session => ("SoundCraft Session", SESSION_EXTS.to_vec()),
|
||||
Self::Audio => ("Audio", FileFormat::readable_extensions()),
|
||||
Self::Midi => ("MIDI", MIDI_EXTS.to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +129,7 @@ impl WebShell {
|
||||
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let (label, exts) = purpose.filter();
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, exts).pick_file().await else { return };
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, &exts).pick_file().await else { return };
|
||||
let bytes = file.read().await;
|
||||
inbox.borrow_mut().push((purpose, file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
@@ -228,17 +227,15 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() {
|
||||
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase());
|
||||
opts.set_type(match ext.as_deref() {
|
||||
Some("scraftz") => "application/zip",
|
||||
Some("wav") => "audio/wav",
|
||||
Some("aif" | "aiff") => "audio/aiff",
|
||||
Some("flac") => "audio/flac",
|
||||
Some("mid" | "midi") => "audio/midi",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("txt") => "text/plain",
|
||||
Some("json" | "scraft") => "application/json",
|
||||
Some("musicxml") => "application/vnd.recordare.musicxml+xml",
|
||||
_ => "application/octet-stream",
|
||||
ext => ext.and_then(FileFormat::from_extension).map_or("application/octet-stream", FileFormat::mime_type),
|
||||
});
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
|
||||
@@ -16,8 +16,11 @@ asio = ["soundcraft-playback/asio"]
|
||||
|
||||
[dependencies]
|
||||
soundcraft-engine.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-ui-egui.workspace = true
|
||||
soundcraft-playback.workspace = true
|
||||
soundcraft-automation.workspace = true
|
||||
clap.workspace = true
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "clipboard"] }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -17,21 +17,13 @@ enum Platform {
|
||||
Other,
|
||||
}
|
||||
|
||||
/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default.
|
||||
/// The override is the complete directory; no application-name suffix is appended. An explicitly
|
||||
/// empty value disables the settings directory instead of falling back to a personal profile.
|
||||
/// Otherwise `portable.txt` beside the executable selects `<exe dir>/SoundCraftData`, when that
|
||||
/// folder can be written (a read-only medium falls back to the platform default).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let exe = std::env::current_exe().ok();
|
||||
resolve(exe.as_deref().and_then(Path::parent))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn resolve(exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
/// SoundCraft's settings folder, resolved from the variable lookup `get` (see `Env::from_lookup`).
|
||||
/// A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default and is the complete
|
||||
/// directory; no application-name suffix is appended. An explicitly empty value means no settings
|
||||
/// folder instead of a fallback to a personal profile (the documented exception to RFC-0012 rule
|
||||
/// 1.7). Otherwise `portable.txt` beside the executable selects `<exe dir>/SoundCraftData`, when
|
||||
/// that folder can be written (a read-only medium falls back to the platform default).
|
||||
pub fn resolve_config_dir(get: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
} else if cfg!(windows) {
|
||||
@@ -39,11 +31,12 @@ fn resolve(exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
} else {
|
||||
Platform::Other
|
||||
};
|
||||
config_dir_with(platform, |name| std::env::var_os(name), exe_dir)
|
||||
let exe = std::env::current_exe().ok();
|
||||
config_dir_with(platform, get, exe.as_deref().and_then(Path::parent))
|
||||
}
|
||||
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") {
|
||||
if let Some(dir) = env(crate::env::CONFIG_DIR) {
|
||||
return (!dir.is_empty()).then(|| PathBuf::from(dir));
|
||||
}
|
||||
if let Some(exe_dir) = exe_dir.filter(|d| d.join(PORTABLE_MARKER).is_file()) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! The environment variables the app reads, read once at startup into [`Env`]. Library crates
|
||||
//! never read these; `main` passes the values down. Platform variables (`HOME`, `DISPLAY`, ...)
|
||||
//! and test-only ones stay with the code that needs them.
|
||||
//!
|
||||
//! `RUST_LOG` is not a field: the logger reads it ([`rust_log`]) before anything else, so that
|
||||
//! the warnings [`Switch`] logs for a malformed value reach the log file.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::switch::Switch;
|
||||
|
||||
pub(crate) const CONFIG_DIR: &str = "SOUNDCRAFT_CONFIG_DIR";
|
||||
pub(crate) const NO_PREFS: &str = "SOUNDCRAFT_NO_PREFS";
|
||||
pub(crate) const NO_SYSTEM_FONTS: &str = "SOUNDCRAFT_NO_SYSTEM_FONTS";
|
||||
pub(crate) const WAYLAND: &str = "SOUNDCRAFT_WAYLAND";
|
||||
pub(crate) const RUST_LOG: &str = "RUST_LOG";
|
||||
|
||||
/// The variables and their `--help` lines, in the order `--help` lists them.
|
||||
pub(crate) const VARS: &[(&str, &str)] = &[
|
||||
// Read by clap (`env =` on `--control`), not by `Env`.
|
||||
("SOUNDCRAFT_CONTROL_PORT", "The --control fallback: a port or host:port, ignored for another host"),
|
||||
(CONFIG_DIR, "The settings folder (ui.json, Autosave, Presets, logs); empty: none (unlike the others, where empty counts as unset)"),
|
||||
(NO_PREFS, "1 doesn't read or write preferences and logs to stderr only; 0 or unset is off"),
|
||||
(NO_SYSTEM_FONTS, "1 uses the built-in fonts only; 0 or unset is off"),
|
||||
(WAYLAND, "Linux: 1 keeps the native Wayland backend instead of XWayland; 0 or unset is off"),
|
||||
(RUST_LOG, "Log filter, env_logger style: debug, warn,soundcraft_mix=trace"),
|
||||
];
|
||||
|
||||
/// The `--help` Environment section, generated from [`VARS`].
|
||||
pub(crate) fn help() -> String {
|
||||
let width = VARS.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
|
||||
let mut out = String::from("Environment:\n");
|
||||
for (name, line) in VARS {
|
||||
out.push_str(&format!(" {name:<width$} {line}\n"));
|
||||
}
|
||||
out.push_str(
|
||||
" The on/off variables take 1/on/true/yes or 0/off/false/no, in any case; any other value is\n ignored with a warning in the log.\n",
|
||||
);
|
||||
out.truncate(out.trim_end().len());
|
||||
out
|
||||
}
|
||||
|
||||
/// The app's variables, as read at startup.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Env {
|
||||
/// The settings folder (ui.json, Autosave, Presets, logs), resolved once: `SOUNDCRAFT_CONFIG_DIR`,
|
||||
/// else the portable data folder, else the platform's. `None` when the variable is set but
|
||||
/// empty, or no platform folder is available. Every other variable treats empty as unset
|
||||
/// (RFC-0012 rule 1.7); this one doesn't.
|
||||
pub(crate) config_dir: Option<PathBuf>,
|
||||
pub(crate) no_prefs: Switch,
|
||||
pub(crate) no_system_fonts: Switch,
|
||||
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
|
||||
pub(crate) wayland: Switch,
|
||||
}
|
||||
|
||||
impl Env {
|
||||
/// Reads the process environment. Call after the logger is installed, so malformed switches are logged.
|
||||
pub(crate) fn read() -> Env {
|
||||
Env::from_lookup(|name| std::env::var_os(name))
|
||||
}
|
||||
|
||||
pub(crate) fn from_lookup(get: impl Fn(&str) -> Option<OsString>) -> Env {
|
||||
let switch = |name: &str| match get(name) {
|
||||
None => Switch::Default,
|
||||
Some(v) => Switch::parse(name, Some(&v.to_string_lossy())),
|
||||
};
|
||||
Env {
|
||||
config_dir: crate::config::resolve_config_dir(&get),
|
||||
no_prefs: switch(NO_PREFS),
|
||||
no_system_fonts: switch(NO_SYSTEM_FONTS),
|
||||
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
|
||||
wayland: switch(WAYLAND),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
|
||||
pub(crate) fn prefs_enabled(&self) -> bool {
|
||||
self.no_prefs != Switch::On
|
||||
}
|
||||
|
||||
pub(crate) fn system_fonts(&self) -> bool {
|
||||
self.no_system_fonts != Switch::On
|
||||
}
|
||||
|
||||
/// Whether to run through XWayland: winit has no file drag-and-drop on Wayland, so dropping
|
||||
/// files from the file manager showed a "no" cursor. Needs a nonempty `DISPLAY`
|
||||
/// (`display`); `SOUNDCRAFT_WAYLAND=1` keeps the native backend.
|
||||
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
|
||||
pub(crate) fn use_xwayland(&self, display: Option<&OsString>) -> bool {
|
||||
display.is_some_and(|d| !d.is_empty()) && self.wayland != Switch::On
|
||||
}
|
||||
}
|
||||
|
||||
/// The `RUST_LOG` filter, `None` when unset or blank.
|
||||
pub(crate) fn rust_log() -> Option<String> {
|
||||
std::env::var(RUST_LOG).ok().filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lookup(vars: &'static [(&'static str, &'static str)]) -> Env {
|
||||
Env::from_lookup(|name| vars.iter().find(|(n, _)| *n == name).map(|(_, v)| OsString::from(v)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_are_read_into_typed_fields() {
|
||||
let env = lookup(&[(CONFIG_DIR, "/c"), (NO_PREFS, "yes"), (NO_SYSTEM_FONTS, "0"), (WAYLAND, "bogus")]);
|
||||
assert_eq!(env.config_dir, Some(PathBuf::from("/c")));
|
||||
assert_eq!((env.no_prefs, env.no_system_fonts, env.wayland), (Switch::On, Switch::Off, Switch::Default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_folder_is_resolved_into_env() {
|
||||
// Empty means no folder, even with a platform folder available.
|
||||
assert_eq!(lookup(&[(CONFIG_DIR, ""), ("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")]).config_dir, None);
|
||||
assert_eq!(lookup(&[(CONFIG_DIR, "/c"), ("HOME", "/h")]).config_dir, Some(PathBuf::from("/c")));
|
||||
let unset = lookup(&[("HOME", "/h"), ("APPDATA", "/h"), ("XDG_CONFIG_HOME", "/x")]).config_dir;
|
||||
assert!(unset.is_some_and(|d| d.ends_with("SoundCraft") || d.ends_with("soundcraft") || d.ends_with("SoundCraftData")));
|
||||
// Disabling preferences doesn't depend on the folder.
|
||||
let env = lookup(&[(CONFIG_DIR, "/c"), (NO_PREFS, "1")]);
|
||||
assert!(env.config_dir.is_some() && !env.prefs_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_prefs_and_no_system_fonts_turn_features_off() {
|
||||
assert!(Env::default().prefs_enabled() && Env::default().system_fonts());
|
||||
let env = Env { no_prefs: Switch::On, no_system_fonts: Switch::On, ..Env::default() };
|
||||
assert!(!env.prefs_enabled() && !env.system_fonts());
|
||||
assert!(Env { no_prefs: Switch::Off, ..Env::default() }.prefs_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xwayland_needs_a_display_and_wayland_not_forced() {
|
||||
let display = OsString::from(":0");
|
||||
assert!(Env::default().use_xwayland(Some(&display)));
|
||||
assert!(Env { wayland: Switch::Off, ..Env::default() }.use_xwayland(Some(&display)));
|
||||
assert!(!Env { wayland: Switch::On, ..Env::default() }.use_xwayland(Some(&display)));
|
||||
assert!(!Env::default().use_xwayland(None));
|
||||
assert!(!Env::default().use_xwayland(Some(&OsString::new())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_every_variable() {
|
||||
let help = help();
|
||||
for (name, line) in VARS {
|
||||
assert!(help.contains(name) && help.contains(line), "{name} missing from\n{help}");
|
||||
}
|
||||
assert_eq!(help.matches("0/off/false/no").count(), 1, "{help}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! The desktop app's command line, as clap types: `soundcraft --help` comes from these doc
|
||||
//! comments, so the help and the parser can't disagree. Anything they don't declare is a usage
|
||||
//! error naming it (exit status 2), as is a bad `--control` or `SOUNDCRAFT_CONTROL_PORT`. A file
|
||||
//! whose name starts with `-` goes after `--`. Another host's `host:port` is a usage error from
|
||||
//! `--control` and ignored from the variable ([`control_port`]).
|
||||
//!
|
||||
//! The control flag is `soundcraft-automation`'s, shared with `soundcraft-cli`.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::builder::StyledStr;
|
||||
use clap::error::{ContextKind, ContextValue, ErrorKind};
|
||||
use clap::parser::ValueSource;
|
||||
use clap::{Command, CommandFactory, FromArgMatches, Parser};
|
||||
use soundcraft_automation::ControlTarget;
|
||||
use soundcraft_automation::args::{ControlServerArgs, blank_env_is_unset, name_env_vars};
|
||||
|
||||
/// Opens SoundCraft with the given files
|
||||
#[derive(Parser)]
|
||||
#[command(name = "soundcraft", version = soundcraft_automation::args::long_version(), after_help = crate::env::help())]
|
||||
pub(crate) struct LaunchArgs {
|
||||
/// Sessions, audio and MIDI files to open. Put `--` before a name that starts with `-`
|
||||
pub(crate) files: Vec<PathBuf>,
|
||||
/// Start with the demo session
|
||||
#[arg(long, visible_alias = "sample")]
|
||||
pub(crate) demo: bool,
|
||||
/// Don't open an audio device
|
||||
#[arg(long)]
|
||||
pub(crate) no_audio: bool,
|
||||
#[command(flatten)]
|
||||
control: ControlServerArgs,
|
||||
/// The port the control server starts on, from `control` ([`control_port`]).
|
||||
#[arg(skip)]
|
||||
pub(crate) control_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// The command line, where an empty or blank variable counts as unset.
|
||||
pub(crate) fn command() -> Command {
|
||||
LaunchArgs::command().mut_args(blank_env_is_unset)
|
||||
}
|
||||
|
||||
/// Parses the launch arguments (without the program name) with `cmd`, from [`command`]. macOS
|
||||
/// passes a process serial number (`-psn_…`) when an app is started from Finder on old versions:
|
||||
/// it is dropped first, unless it comes after `--`. A bad value from a variable is a usage error
|
||||
/// naming the variable.
|
||||
pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator<Item = OsString>) -> Result<LaunchArgs, clap::Error> {
|
||||
let mut options = true;
|
||||
let args: Vec<OsString> = args
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
options &= a != "--";
|
||||
!(options && a.to_str().is_some_and(|s| s.starts_with("-psn_")))
|
||||
})
|
||||
.collect();
|
||||
let argv = std::iter::once(OsString::from("soundcraft")).chain(args.iter().cloned());
|
||||
let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?;
|
||||
let mut launch = LaunchArgs::from_arg_matches(&matches)?;
|
||||
launch.control_port = control_port(launch.control.control.take(), matches.value_source("control")).map_err(|e| e.into_clap(&mut cmd))?;
|
||||
Ok(launch)
|
||||
}
|
||||
|
||||
/// The port the control server starts on, from `--control` or `SOUNDCRAFT_CONTROL_PORT`
|
||||
/// (`source`). The server listens on 127.0.0.1 only, so it can't serve another host's
|
||||
/// `host:port`. From the flag that is a usage error. The variable is `soundcraft-cli app`'s too and
|
||||
/// may point it at another machine, so from there it is ignored with a warning and the app opens
|
||||
/// without a control server.
|
||||
fn control_port(value: Option<ControlTarget>, source: Option<ValueSource>) -> Result<Option<u16>, RemoteHost> {
|
||||
match value {
|
||||
None => Ok(None),
|
||||
Some(target) if target.is_loopback() => Ok(Some(target.port)),
|
||||
Some(target) if source == Some(ValueSource::EnvVariable) => {
|
||||
log::warn!("SOUNDCRAFT_CONTROL_PORT={target} names another host, so no control server starts: it listens on 127.0.0.1 only");
|
||||
Ok(None)
|
||||
}
|
||||
Some(target) => Err(RemoteHost(target)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `--control` `host:port` naming another host.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct RemoteHost(ControlTarget);
|
||||
|
||||
impl RemoteHost {
|
||||
/// clap's invalid-value error for `--control`, with the reason as a tip.
|
||||
fn into_clap(self, cmd: &mut Command) -> clap::Error {
|
||||
let flag = cmd.get_arguments().find(|a| a.get_id() == "control").map_or_else(|| "--control".to_owned(), ToString::to_string);
|
||||
let tip =
|
||||
StyledStr::from("the control server listens on 127.0.0.1 only: give a port, or host:port with a loopback host (127.0.0.1, localhost)");
|
||||
let mut error = clap::Error::new(ErrorKind::ValueValidation).with_cmd(cmd);
|
||||
error.insert(ContextKind::InvalidArg, ContextValue::String(flag));
|
||||
error.insert(ContextKind::InvalidValue, ContextValue::String(self.0.to_string()));
|
||||
error.insert(ContextKind::Suggested, ContextValue::StyledStrs(vec![tip]));
|
||||
error.insert(ContextKind::Usage, ContextValue::StyledStr(cmd.render_usage()));
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::error::ErrorKind;
|
||||
|
||||
/// Parse without the variable, so one exported where the tests run can't change them (the
|
||||
/// process tests in `tests/launch_flags.rs` cover it).
|
||||
fn parse_args(args: &[&str]) -> Result<LaunchArgs, clap::Error> {
|
||||
parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from))
|
||||
}
|
||||
|
||||
fn usage_error(args: &[&str]) -> String {
|
||||
let Err(e) = parse_args(args) else { panic!("{args:?} parsed") };
|
||||
assert_eq!(e.exit_code(), 2, "{args:?}: {e}");
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_is_consistent() {
|
||||
command().debug_assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_and_files_are_parsed() {
|
||||
let a = parse_args(&["--demo", "--no-audio", "--control", "50494", "a.scraft", "b.wav"]).unwrap();
|
||||
assert!(a.demo && a.no_audio);
|
||||
assert_eq!(a.control_port, Some(50494));
|
||||
assert_eq!(a.files, [PathBuf::from("a.scraft"), PathBuf::from("b.wav")]);
|
||||
assert!(parse_args(&["--sample"]).unwrap().demo);
|
||||
let none = parse_args(&[]).unwrap();
|
||||
assert!(!none.demo && !none.no_audio && none.control_port.is_none() && none.files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_and_help_stop_parsing() {
|
||||
for flag in ["--version", "-V"] {
|
||||
let e = parse_args(&[flag, "--control"]).err().unwrap();
|
||||
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0));
|
||||
assert_eq!(e.to_string(), format!("soundcraft {}\n", soundcraft_automation::args::long_version()));
|
||||
}
|
||||
let e = parse_args(&["-h", "--bogus"]).err().unwrap();
|
||||
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0));
|
||||
let help = e.to_string();
|
||||
for text in ["--demo", "--sample", "--no-audio", "--control <PORT>"] {
|
||||
assert!(help.contains(text), "{text}: {help}");
|
||||
}
|
||||
let help = command().render_help().to_string();
|
||||
assert!(help.contains("[env: SOUNDCRAFT_CONTROL_PORT"), "{help}");
|
||||
for var in ["SOUNDCRAFT_CONFIG_DIR", "SOUNDCRAFT_NO_PREFS", "SOUNDCRAFT_NO_SYSTEM_FONTS", "SOUNDCRAFT_WAYLAND", "RUST_LOG"] {
|
||||
assert!(help.contains(&format!("\n {var} ")), "{var}: {help}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_control_port_is_a_usage_error_naming_it() {
|
||||
assert!(usage_error(&["--control", "78787"]).contains("invalid value '78787' for '--control <PORT>'"));
|
||||
assert!(usage_error(&["--control", "nope"]).contains("invalid value 'nope'"));
|
||||
assert!(usage_error(&["--control"]).contains("a value is required for '--control <PORT>'"));
|
||||
assert!(usage_error(&["--control", "--demo"]).contains("a value is required for '--control <PORT>'"));
|
||||
assert!(usage_error(&["--control", "-1"]).contains("invalid value '-1' for '--control <PORT>'"));
|
||||
let remote = usage_error(&["--control", "10.0.0.5:7979"]);
|
||||
assert!(
|
||||
remote.starts_with("error: invalid value '10.0.0.5:7979' for '--control <PORT>'\n\n tip: the control server listens on 127.0.0.1 only"),
|
||||
"{remote}"
|
||||
);
|
||||
assert!(remote.contains("\nUsage: soundcraft "), "{remote}");
|
||||
assert!(usage_error(&["--control", "::1:7979"]).contains("invalid value '::1:7979' for '--control <PORT>'"));
|
||||
}
|
||||
|
||||
/// The variable may point `soundcraft-cli app` at another machine: the app then opens without
|
||||
/// a control server.
|
||||
#[test]
|
||||
fn another_host_is_ignored_only_from_the_variable() {
|
||||
let remote = || ControlTarget { host: Some("10.0.0.5".to_owned()), port: 7979 };
|
||||
assert_eq!(control_port(Some(remote()), Some(ValueSource::EnvVariable)), Ok(None));
|
||||
assert_eq!(control_port(Some(remote()), Some(ValueSource::CommandLine)), Err(RemoteHost(remote())));
|
||||
let local = ControlTarget { host: None, port: 7979 };
|
||||
assert_eq!(control_port(Some(local), Some(ValueSource::EnvVariable)), Ok(Some(7979)));
|
||||
assert_eq!(control_port(None, None), Ok(None));
|
||||
}
|
||||
|
||||
/// The variable `soundcraft-cli app` reads may hold `host:port`: a loopback one gives its port.
|
||||
#[test]
|
||||
fn a_loopback_host_and_port_gives_its_port() {
|
||||
for value in ["127.0.0.1:50494", "localhost:50494"] {
|
||||
assert_eq!(parse_args(&["--control", value]).unwrap().control_port, Some(50494), "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The control server binds 127.0.0.1, so `[::1]` is another host.
|
||||
#[test]
|
||||
fn an_ipv6_loopback_host_is_not_loopback() {
|
||||
assert!(usage_error(&["--control", "[::1]:50494"]).contains("invalid value '[::1]:50494' for '--control <PORT>'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_and_repeats_are_usage_errors() {
|
||||
assert!(usage_error(&["--bogus"]).contains("unexpected argument '--bogus'"));
|
||||
assert!(usage_error(&["-x"]).contains("unexpected argument '-x'"));
|
||||
assert!(usage_error(&["--demo", "--demo"]).contains("cannot be used multiple times"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_dash_ends_options() {
|
||||
let a = parse_args(&["a.wav", "--", "--demo", "-x"]).unwrap();
|
||||
assert!(!a.demo);
|
||||
assert_eq!(a.files, [PathBuf::from("a.wav"), PathBuf::from("--demo"), PathBuf::from("-x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finder_process_serial_number_is_dropped() {
|
||||
assert_eq!(parse_args(&["-psn_0_12345", "a.wav"]).unwrap().files, [PathBuf::from("a.wav")]);
|
||||
assert_eq!(parse_args(&["--", "-psn_x.wav"]).unwrap().files, [PathBuf::from("-psn_x.wav")]);
|
||||
}
|
||||
|
||||
/// A file name that isn't valid Unicode reaches `files` as it is.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_file_names_are_kept() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
let latin1 = OsString::from_vec(b"caf\xe9.wav".to_vec());
|
||||
let a = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone()]).unwrap();
|
||||
assert_eq!(a.files, [PathBuf::from(latin1)]);
|
||||
}
|
||||
}
|
||||
@@ -413,13 +413,12 @@ impl log::Log for AppLogger {
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// Install the logger (filter from `rust_log`, the `RUST_LOG` value, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] or [`AppLogger::no_file`] once the
|
||||
/// settings directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
pub fn install(rust_log: Option<&str>) -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
||||
let filter = Filter::parse(rust_log.unwrap_or(DEFAULT_FILTER));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
|
||||
+47
-84
@@ -1,6 +1,6 @@
|
||||
//! SoundCraft desktop app.
|
||||
//!
|
||||
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`
|
||||
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`; `soundcraft --help` lists the flags and `SOUNDCRAFT_CONTROL_PORT`.
|
||||
//!
|
||||
//! `--control <port>` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server
|
||||
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` →
|
||||
@@ -13,17 +13,22 @@
|
||||
|
||||
mod config;
|
||||
mod control_server;
|
||||
mod env;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod launch_args;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod switch;
|
||||
|
||||
use env::Env;
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
struct App(SoundApp, Option<&'static logging::AppLogger>, Env, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back.
|
||||
fn set_title(ctx: &egui::Context, title: String) {
|
||||
@@ -43,20 +48,20 @@ impl eframe::App for App {
|
||||
logger.report_audio_thread();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.2 {
|
||||
if let Some(menu) = &mut self.3 {
|
||||
menu.process(&mut self.0);
|
||||
}
|
||||
self.0.guarded(|app| app.logic(ctx));
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.2 {
|
||||
if let Some(menu) = &mut self.3 {
|
||||
menu.sync(&self.0);
|
||||
}
|
||||
// Files dropped on the window: audio on an instrument track or a Sampler window loads
|
||||
// the sampler; otherwise sessions open and audio/MIDI import.
|
||||
let dropped: Vec<String> =
|
||||
self.0.take_dropped_files().iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect();
|
||||
for p in dropped {
|
||||
open_path(&mut self.0, &p);
|
||||
for f in self.0.take_dropped_files() {
|
||||
if !f.path().as_os_str().is_empty() {
|
||||
dialogs::open_path(&mut self.0, f.path());
|
||||
}
|
||||
}
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -72,7 +77,7 @@ impl eframe::App for App {
|
||||
self.0.ui.ui_scale = self.0.extra.ui_scale;
|
||||
// Saved now rather than at exit: the recent list matters most after a crash.
|
||||
if self.0.take_recent_changed() {
|
||||
save_prefs(&self.0.ui);
|
||||
save_prefs(&self.2, &self.0.ui);
|
||||
}
|
||||
// With the system menu bar there is no egui bar showing the window and dirty state, so the
|
||||
// title carries them; elsewhere the egui menu bar already does.
|
||||
@@ -98,7 +103,7 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
self.0.close_autosave();
|
||||
save_prefs(&self.0.ui);
|
||||
save_prefs(&self.2, &self.0.ui);
|
||||
if let Some(logger) = self.1 {
|
||||
logger.report_audio_thread();
|
||||
}
|
||||
@@ -109,42 +114,16 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_path(app: &mut SoundApp, p: &str) {
|
||||
let lower = p.to_ascii_lowercase();
|
||||
let r = if lower.ends_with(".scraft") {
|
||||
dialogs::run_or_ask(app, "session.open", serde_json::json!({"path": p}))
|
||||
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
|
||||
app.run("file.import_midi", serde_json::json!({"path": p}))
|
||||
} else {
|
||||
// Decoded in the background so a long file doesn't freeze the window (#597).
|
||||
let at = app.engine.session().edit.selection.start;
|
||||
soundcraft_ui_egui::import::start(app, serde_json::json!({"path": p, "at": at}));
|
||||
Ok(serde_json::Value::Null)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::warn!("{p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
config::config_dir()
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
config_dir().map(|b| b.join("ui.json"))
|
||||
fn prefs_path(env: &Env) -> Option<std::path::PathBuf> {
|
||||
env.config_dir.as_ref().map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
/// Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
|
||||
fn prefs_enabled() -> bool {
|
||||
std::env::var_os("SOUNDCRAFT_NO_PREFS").is_none()
|
||||
}
|
||||
|
||||
fn load_prefs() -> Option<UiState> {
|
||||
if !prefs_enabled() {
|
||||
fn load_prefs(env: &Env) -> Option<UiState> {
|
||||
if !env.prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let path = prefs_path()?;
|
||||
let path = prefs_path(env)?;
|
||||
match config::load_ui(&path) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
@@ -154,11 +133,11 @@ fn load_prefs() -> Option<UiState> {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_prefs(ui: &UiState) {
|
||||
if !prefs_enabled() {
|
||||
fn save_prefs(env: &Env, ui: &UiState) {
|
||||
if !env.prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(path) = prefs_path()
|
||||
if let Some(path) = prefs_path(env)
|
||||
&& let Err(error) = config::save_ui(&path, ui)
|
||||
{
|
||||
log::warn!("{error}");
|
||||
@@ -172,17 +151,13 @@ fn services() -> Services {
|
||||
let d = match purpose {
|
||||
"session.open" => d.add_filter("SoundCraft Session", &["scraft"]),
|
||||
"file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]),
|
||||
_ => d.add_filter(
|
||||
"Audio",
|
||||
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"],
|
||||
),
|
||||
_ => d.add_filter("Audio", &FileFormat::readable_extensions()),
|
||||
};
|
||||
d.pick_file().map(|p| p.to_string_lossy().into_owned())
|
||||
d.pick_file()
|
||||
})),
|
||||
pick_save: Some(Box::new(|purpose: &str, default: &str| {
|
||||
let path = std::path::Path::new(default);
|
||||
pick_save: Some(Box::new(|purpose: &str, path: &std::path::Path| {
|
||||
let mut d = match purpose {
|
||||
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &["wav", "aif", "aiff", "flac"]),
|
||||
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &FileFormat::writable_extensions()),
|
||||
_ => rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]),
|
||||
};
|
||||
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
|
||||
@@ -191,11 +166,9 @@ fn services() -> Services {
|
||||
if let Some(n) = path.file_name().and_then(std::ffi::OsStr::to_str) {
|
||||
d = d.set_file_name(n);
|
||||
}
|
||||
d.save_file().map(|p| p.to_string_lossy().into_owned())
|
||||
})),
|
||||
pick_folder: Some(Box::new(|start: &str| {
|
||||
rfd::FileDialog::new().set_directory(start).pick_folder().map(|p| p.to_string_lossy().into_owned())
|
||||
d.save_file()
|
||||
})),
|
||||
pick_folder: Some(Box::new(|start: &std::path::Path| rfd::FileDialog::new().set_directory(start).pick_folder())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,34 +190,22 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
// Engine) is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("SoundCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
let demo = args.iter().any(|a| a == "--demo" || a == "--sample");
|
||||
let no_audio = args.iter().any(|a| a == "--no-audio");
|
||||
let control_port: Option<u16> = args
|
||||
.iter()
|
||||
.position(|a| a == "--control")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|p| p.parse().ok())
|
||||
.or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
|
||||
let files: Vec<String> = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, a)| !(a.starts_with("--") || (i > &0 && args.get(i - 1).is_some_and(|p| p == "--control"))))
|
||||
.map(|(_, a)| a.clone())
|
||||
.collect();
|
||||
log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
let logger = logging::install(env::rust_log().as_deref());
|
||||
// Help, version and usage errors print and exit here, before a window or a log file exists.
|
||||
let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit());
|
||||
// After the arguments and the logger: reading resolves the settings folder, which creates it,
|
||||
// and a malformed switch is logged.
|
||||
let env = Env::read();
|
||||
let (demo, no_audio, control_port) = (launch.demo, launch.no_audio, launch.control_port);
|
||||
let files = launch.files;
|
||||
log::info!("SoundCraft {}", soundcraft_automation::args::long_version());
|
||||
// The log file lives in the settings directory; opened after the arguments, so `--version`
|
||||
// leaves no file behind. Records logged until now are written to it first. Runs without
|
||||
// preferences (agents' test runs) log to standard error only, so they don't rotate away the
|
||||
// user's own logs.
|
||||
if let Some(logger) = logger {
|
||||
match config_dir() {
|
||||
_ if !prefs_enabled() => logger.no_file(),
|
||||
match env.config_dir.as_deref() {
|
||||
_ if !env.prefs_enabled() => logger.no_file(),
|
||||
Some(dir) => match logger.attach_dir(&dir.join(logging::LOG_DIR)) {
|
||||
Ok(path) => log::info!("log file {}", path.display()),
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
@@ -257,7 +218,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
|
||||
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
||||
let prefs = load_prefs().unwrap_or_default();
|
||||
let prefs = load_prefs(&env).unwrap_or_default();
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("SoundCraft")
|
||||
@@ -278,7 +239,7 @@ fn main() -> eframe::Result {
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; SOUNDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("SOUNDCRAFT_WAYLAND").is_none() {
|
||||
if env.use_xwayland(std::env::var_os("DISPLAY").as_ref()) {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
@@ -292,9 +253,10 @@ fn main() -> eframe::Result {
|
||||
if no_audio { None } else { Some(soundcraft_playback::Player::with_config(Arc::new(engine.session().clone()), &prefs.audio)) };
|
||||
let mut app = SoundApp::new(engine, player, services());
|
||||
app.set_startup_update_check(true);
|
||||
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
|
||||
app.set_system_fonts(env.system_fonts());
|
||||
app.autosave_dir = env.config_dir.as_ref().map(|d| d.join("Autosave"));
|
||||
app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default();
|
||||
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
|
||||
app.preset_dir = env.config_dir.as_ref().map(|d| d.join("Presets"));
|
||||
app.extra.ui_scale = prefs.ui_scale;
|
||||
app.ui = prefs;
|
||||
if let Some(port) = control_port {
|
||||
@@ -302,7 +264,7 @@ fn main() -> eframe::Result {
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
for f in &files {
|
||||
open_path(&mut app, f);
|
||||
dialogs::open_path(&mut app, f);
|
||||
}
|
||||
// If the system menu bar cannot be built, keep the egui menu bar rather than fail to start.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -319,6 +281,7 @@ fn main() -> eframe::Result {
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
logger,
|
||||
env,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Boolean environment switches (`SOUNDCRAFT_WAYLAND`, `SOUNDCRAFT_NO_PREFS`, ...): one parser, so every
|
||||
//! variable accepts the same spellings.
|
||||
|
||||
/// `1/on/true/yes` or `0/off/false/no`, case- and space-insensitive. Blank or unset is `Default`;
|
||||
/// anything else is `Default` and logged.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum Switch {
|
||||
On,
|
||||
Off,
|
||||
#[default]
|
||||
Default,
|
||||
}
|
||||
|
||||
impl Switch {
|
||||
/// Parse the value of the variable `name`; `name` only appears in the warning.
|
||||
pub(crate) fn parse(name: &str, value: Option<&str>) -> Switch {
|
||||
let Some(value) = value else { return Switch::Default };
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "on" | "true" | "yes" => Switch::On,
|
||||
"0" | "off" | "false" | "no" => Switch::Off,
|
||||
"" => Switch::Default,
|
||||
_ => {
|
||||
log::warn!("{name}={value:?} is not 1/on/true/yes or 0/off/false/no; using the default");
|
||||
Switch::Default
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_every_spelling() {
|
||||
for v in ["1", "on", "true", "yes", "ON", "True", " Yes ", "\t1\n"] {
|
||||
assert_eq!(Switch::parse("X", Some(v)), Switch::On, "{v:?}");
|
||||
}
|
||||
for v in ["0", "off", "false", "no", "OFF", "False", " No ", " 0 "] {
|
||||
assert_eq!(Switch::parse("X", Some(v)), Switch::Off, "{v:?}");
|
||||
}
|
||||
for v in [None, Some(""), Some(" "), Some("maybe"), Some("2"), Some("onn")] {
|
||||
assert_eq!(Switch::parse("X", v), Switch::Default, "{v:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The desktop binary's command line, run as a process: the variable fallback of `--control`.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn soundcraft(args: &[&str], control_port: &str) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_soundcraft"))
|
||||
.args(args)
|
||||
.env("SOUNDCRAFT_NO_PREFS", "1")
|
||||
.env("SOUNDCRAFT_CONTROL_PORT", control_port)
|
||||
.env_remove("RUST_LOG")
|
||||
// Without a display the window cannot open, so a launch that passes parsing ends instead of
|
||||
// staying up.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.output()
|
||||
.expect("run soundcraft")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_variable_is_a_usage_error_naming_it() {
|
||||
let out = soundcraft(&["--no-audio"], "nope");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "{stderr}");
|
||||
assert!(stderr.contains("invalid value 'nope' for 'SOUNDCRAFT_CONTROL_PORT'"), "{stderr}");
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn a_blank_variable_is_unset() {
|
||||
let out = soundcraft(&["--no-audio"], " ");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_ne!(out.status.code(), Some(2), "{stderr}");
|
||||
assert!(!stderr.contains("SOUNDCRAFT_CONTROL_PORT"), "{stderr}");
|
||||
}
|
||||
|
||||
/// `soundcraft-cli app` takes `host:port` from the same variable.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn a_loopback_host_and_port_variable_is_accepted() {
|
||||
let out = soundcraft(&["--no-audio"], "127.0.0.1:0");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_ne!(out.status.code(), Some(2), "{stderr}");
|
||||
assert!(!stderr.contains("SOUNDCRAFT_CONTROL_PORT"), "{stderr}");
|
||||
}
|
||||
|
||||
/// The variable may point `soundcraft-cli app` at another machine: the app opens, with a warning
|
||||
/// and no control server.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn another_host_in_the_variable_is_ignored() {
|
||||
let out = soundcraft(&["--no-audio"], "10.0.0.5:7979");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_ne!(out.status.code(), Some(2), "{stderr}");
|
||||
assert!(stderr.contains("SOUNDCRAFT_CONTROL_PORT=10.0.0.5:7979 names another host, so no control server starts"), "{stderr}");
|
||||
}
|
||||
@@ -2,4 +2,8 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
+102
-27
@@ -61,9 +61,110 @@ pub enum FileFormat {
|
||||
Aac,
|
||||
Alac,
|
||||
Caf,
|
||||
/// Any other container `symphonia` reads: Matroska and WebM by name.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl FileFormat {
|
||||
/// The formats [`decode`] reads, found by extension. Contents decide when they disagree.
|
||||
pub const READ: &'static [FileFormat] =
|
||||
&[FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac, FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Caf, FileFormat::Other];
|
||||
/// The formats [`encode`] writes.
|
||||
pub const WRITE: &'static [FileFormat] = &[FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac];
|
||||
|
||||
/// The extensions that name this format, lower case and without the dot; the first is the one
|
||||
/// a new file gets. ALAC shares AAC's MP4 container, so a `.m4a` name reads as AAC until the
|
||||
/// contents say otherwise.
|
||||
pub fn extensions(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
FileFormat::Wav => &["wav", "wave", "bwf", "rf64"],
|
||||
FileFormat::Aiff => &["aif", "aiff", "aifc"],
|
||||
FileFormat::Flac => &["flac"],
|
||||
FileFormat::Mp3 => &["mp3", "mp2", "mp1"],
|
||||
FileFormat::Ogg => &["ogg", "oga"],
|
||||
FileFormat::Aac => &["m4a", "m4b", "aac", "mp4", "adts"],
|
||||
FileFormat::Alac => &["m4a"],
|
||||
FileFormat::Caf => &["caf"],
|
||||
FileFormat::Other => &["mkv", "webm"],
|
||||
}
|
||||
}
|
||||
|
||||
/// The name people know the format by, as menus show it.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FileFormat::Wav => "WAV",
|
||||
FileFormat::Aiff => "AIFF",
|
||||
FileFormat::Flac => "FLAC",
|
||||
FileFormat::Mp3 => "MP3",
|
||||
FileFormat::Ogg => "Ogg Vorbis",
|
||||
FileFormat::Aac => "AAC",
|
||||
FileFormat::Alac => "ALAC",
|
||||
FileFormat::Caf => "CAF",
|
||||
FileFormat::Other => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
/// The media type a file of this format is served or downloaded as.
|
||||
pub fn mime_type(self) -> &'static str {
|
||||
match self {
|
||||
FileFormat::Wav => "audio/wav",
|
||||
FileFormat::Aiff => "audio/aiff",
|
||||
FileFormat::Flac => "audio/flac",
|
||||
FileFormat::Mp3 => "audio/mpeg",
|
||||
FileFormat::Ogg => "audio/ogg",
|
||||
FileFormat::Aac | FileFormat::Alac => "audio/mp4",
|
||||
FileFormat::Caf => "audio/x-caf",
|
||||
FileFormat::Other => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension a new file of this format gets.
|
||||
pub fn default_extension(self) -> &'static str {
|
||||
self.extensions().first().copied().unwrap_or("bin")
|
||||
}
|
||||
|
||||
/// The readable format an extension or format name names (`wav`, `.AIFF`; ASCII
|
||||
/// case-insensitive), or `None` when nothing reads it.
|
||||
pub fn from_extension(ext: &str) -> Option<FileFormat> {
|
||||
let ext = ext.trim_start_matches('.');
|
||||
Self::READ.iter().copied().find(|f| f.extensions().iter().any(|x| x.eq_ignore_ascii_case(ext)))
|
||||
}
|
||||
|
||||
/// The readable format `path`'s extension names, or `None` without a known extension.
|
||||
pub fn from_path(path: impl AsRef<std::path::Path>) -> Option<FileFormat> {
|
||||
path.as_ref().extension().and_then(std::ffi::OsStr::to_str).and_then(Self::from_extension)
|
||||
}
|
||||
|
||||
/// The extensions [`encode`] writes this format under: the names whose readers expect what it
|
||||
/// writes. RF64 and AIFF-C names are left out because `encode` writes plain RIFF and AIFF
|
||||
/// headers for them.
|
||||
pub fn write_extensions(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
FileFormat::Wav => &["wav", "wave", "bwf"],
|
||||
FileFormat::Aiff => &["aif", "aiff"],
|
||||
FileFormat::Flac => &["flac"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The format [`encode`] writes for an extension or format name (ASCII case-insensitive), or
|
||||
/// `None` when it writes none.
|
||||
pub fn from_write_extension(ext: &str) -> Option<FileFormat> {
|
||||
let ext = ext.trim_start_matches('.');
|
||||
Self::WRITE.iter().copied().find(|f| f.write_extensions().iter().any(|x| x.eq_ignore_ascii_case(ext)))
|
||||
}
|
||||
|
||||
/// Every extension of [`FileFormat::READ`], in order: what file pickers and browsers filter by.
|
||||
pub fn readable_extensions() -> Vec<&'static str> {
|
||||
Self::READ.iter().flat_map(|f| f.extensions().iter().copied()).collect()
|
||||
}
|
||||
|
||||
/// Every extension of [`FileFormat::WRITE`], in order: what save dialogs, help and errors list.
|
||||
pub fn writable_extensions() -> Vec<&'static str> {
|
||||
Self::WRITE.iter().flat_map(|f| f.write_extensions().iter().copied()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored sample representation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum SampleFormat {
|
||||
@@ -192,22 +293,9 @@ fn detect_by_magic(b: &[u8], depth: u32) -> Option<FileFormat> {
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_by_ext(ext: &str) -> FileFormat {
|
||||
match ext.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
||||
"wav" | "wave" | "bwf" | "rf64" => FileFormat::Wav,
|
||||
"aif" | "aiff" | "aifc" => FileFormat::Aiff,
|
||||
"flac" => FileFormat::Flac,
|
||||
"mp3" | "mp2" | "mp1" => FileFormat::Mp3,
|
||||
"ogg" | "oga" => FileFormat::Ogg,
|
||||
"aac" | "m4a" | "m4b" | "mp4" | "adts" => FileFormat::Aac,
|
||||
"caf" => FileFormat::Caf,
|
||||
_ => FileFormat::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Identify a file by its magic bytes, falling back to the extension hint (`"wav"`, `".flac"`).
|
||||
pub fn detect_format(bytes: &[u8], ext_hint: Option<&str>) -> FileFormat {
|
||||
detect_by_magic(bytes, 0).unwrap_or_else(|| ext_hint.map(detect_by_ext).unwrap_or(FileFormat::Other))
|
||||
detect_by_magic(bytes, 0).unwrap_or_else(|| ext_hint.and_then(FileFormat::from_extension).unwrap_or(FileFormat::Other))
|
||||
}
|
||||
|
||||
/// Read a file's format, sample rate, channel count, length and BWF metadata.
|
||||
@@ -270,19 +358,6 @@ pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats [`encode`] can write, as the extensions that name them.
|
||||
pub const ENCODE_EXTENSIONS: &str = "wav, aif, aiff, flac";
|
||||
|
||||
/// The format [`encode`] writes for a file extension or format name (`wav`, `aiff`, `flac`, case-insensitive), or `None` when it cannot write it.
|
||||
pub fn encode_format_for(name: &str) -> Option<FileFormat> {
|
||||
match name.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
||||
"wav" | "wave" | "bwf" => Some(FileFormat::Wav),
|
||||
"aif" | "aiff" => Some(FileFormat::Aiff),
|
||||
"flac" => Some(FileFormat::Flac),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a WAV file in the RF64 layout regardless of size (normally used only above 4 GiB).
|
||||
pub fn encode_wav_rf64(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
|
||||
wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true, None)
|
||||
|
||||
@@ -159,10 +159,17 @@ impl Quantizer {
|
||||
v += self.uniform() - self.uniform();
|
||||
}
|
||||
let max = scale - 1.0;
|
||||
v.round().clamp(-scale, max) as i32
|
||||
round_half_away(v.clamp(-scale, max)) as i32
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn round_half_away(v: f64) -> f64 {
|
||||
let t = v as i64;
|
||||
let f = v - t as f64;
|
||||
(t + i64::from(f >= 0.5) - i64::from(f <= -0.5)) as f64
|
||||
}
|
||||
|
||||
/// Bits per sample at `depth`, and whether they are float.
|
||||
pub(crate) fn sample_format(depth: BitDepth) -> (u16, bool) {
|
||||
match depth {
|
||||
@@ -235,3 +242,38 @@ pub(crate) fn validate_layout(channels: usize, sample_rate: u32) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_half_away_matches_f64_round_on_the_quantizer_range() {
|
||||
let edges = [
|
||||
0.0,
|
||||
-0.0,
|
||||
0.5,
|
||||
-0.5,
|
||||
1.5,
|
||||
-1.5,
|
||||
2.5,
|
||||
0.499_999_999_999_999_94,
|
||||
-0.499_999_999_999_999_94,
|
||||
8_388_607.5,
|
||||
-8_388_608.0,
|
||||
2_147_483_647.0,
|
||||
-2_147_483_648.0,
|
||||
];
|
||||
let mut x = 0x2545_f491_4f6c_dd1du64;
|
||||
let spread = (0..10_000).map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
(x >> 11) as f64 / (1u64 << 53) as f64 * 4_294_967_295.0 - 2_147_483_648.0
|
||||
});
|
||||
for v in edges.into_iter().chain(spread) {
|
||||
assert!(round_half_away(v) == v.round(), "{v}");
|
||||
assert_eq!(round_half_away(v) as i32, v.round() as i32, "{v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
Tiny fixtures for `tests/it/decode_read_list.rs`: one per container or codec the READ list names
|
||||
that the crate cannot write itself. Made with ffmpeg 8.0 from a 0.1 s mono 440 Hz sine
|
||||
(`F="-y -f lavfi -i sine=frequency=440:duration=0.1 -ac 1"`):
|
||||
|
||||
ffmpeg $F -ar 8000 -c:a libmp3lame -b:a 16k tone.mp3
|
||||
ffmpeg $F -ar 24000 -c:a mp2 -b:a 32k tone.mp2
|
||||
ffmpeg $F -ar 8000 -c:a libvorbis -q:a -1 tone.ogg
|
||||
ffmpeg $F -ar 8000 -c:a aac -b:a 16k tone.m4a
|
||||
ffmpeg $F -ar 8000 -c:a aac -b:a 16k -f adts tone.aac
|
||||
ffmpeg $F -ar 8000 -c:a pcm_s16le tone.caf
|
||||
ffmpeg $F -ar 8000 -c:a pcm_s16le tone.mkv
|
||||
ffmpeg $F -ar 8000 -c:a libvorbis -q:a -1 tone.webm
|
||||
ffmpeg $F -ar 8000 -c:a pcm_s16le -f aiff tone.aifc # AIFF-C "sowt"
|
||||
|
||||
`silence.mp1` has no ffmpeg encoder. It is four hand-built MPEG-1 Layer I frames (mono, 32 kHz,
|
||||
32 kbit/s, 48 bytes each: header `FF FF 18 C0`, all subband allocations zero), which decode to
|
||||
silence.
|
||||
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.
@@ -0,0 +1,51 @@
|
||||
//! Every extension the format table reads decodes with the compiled-in `symphonia` features.
|
||||
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat, decode, encode, probe};
|
||||
|
||||
fn fixture(name: &str) -> Vec<u8> {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name);
|
||||
std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn generated(format: FileFormat) -> Vec<u8> {
|
||||
let tone: Vec<f32> = (0..800).map(|i| (i as f32 * 0.1).sin() * 0.5).collect();
|
||||
let buf = AudioBuffer { sample_rate: 8000, channels: vec![tone] };
|
||||
encode(&buf, &EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None }).unwrap()
|
||||
}
|
||||
|
||||
fn bytes_for(ext: &str) -> Vec<u8> {
|
||||
match ext {
|
||||
"wav" | "wave" | "bwf" => generated(FileFormat::Wav),
|
||||
"rf64" => {
|
||||
let tone = AudioBuffer { sample_rate: 8000, channels: vec![vec![0.25; 800]] };
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
soundcraft_audio_io::encode_wav_rf64(&tone, &opts).unwrap()
|
||||
}
|
||||
"aif" | "aiff" => generated(FileFormat::Aiff),
|
||||
"aifc" => fixture("tone.aifc"),
|
||||
"flac" => generated(FileFormat::Flac),
|
||||
"mp3" => fixture("tone.mp3"),
|
||||
"mp2" => fixture("tone.mp2"),
|
||||
"mp1" => fixture("silence.mp1"),
|
||||
"ogg" | "oga" => fixture("tone.ogg"),
|
||||
"m4a" | "m4b" | "mp4" => fixture("tone.m4a"),
|
||||
"aac" | "adts" => fixture("tone.aac"),
|
||||
"caf" => fixture("tone.caf"),
|
||||
"mkv" => fixture("tone.mkv"),
|
||||
"webm" => fixture("tone.webm"),
|
||||
other => panic!("no fixture for the READ extension {other:?}: add one"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_read_extension_probes_and_decodes() {
|
||||
for &format in FileFormat::READ {
|
||||
for ext in format.extensions() {
|
||||
let bytes = bytes_for(ext);
|
||||
let info = probe(&bytes, Some(ext)).unwrap_or_else(|e| panic!("probe .{ext}: {e}"));
|
||||
let (_, buf) = decode(&bytes, Some(ext)).unwrap_or_else(|e| panic!("decode .{ext}: {e}"));
|
||||
assert!(info.channels >= 1, ".{ext}");
|
||||
assert!(buf.frames() > 0, ".{ext}: no frames");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
use soundcraft_audio_io::{FileFormat, encode_format_for};
|
||||
|
||||
#[test]
|
||||
fn only_formats_the_encoder_writes_are_named() {
|
||||
assert_eq!(encode_format_for("wav"), Some(FileFormat::Wav));
|
||||
assert_eq!(encode_format_for("WAV"), Some(FileFormat::Wav));
|
||||
assert_eq!(encode_format_for(".aif"), Some(FileFormat::Aiff));
|
||||
assert_eq!(encode_format_for("aiff"), Some(FileFormat::Aiff));
|
||||
assert_eq!(encode_format_for("flac"), Some(FileFormat::Flac));
|
||||
for other in ["mp3", "ogg", "m4a", "xyz", ""] {
|
||||
assert_eq!(encode_format_for(other), None, "{other}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
|
||||
#[test]
|
||||
fn every_read_and_write_format_round_trips_through_its_extensions() {
|
||||
for &format in FileFormat::READ {
|
||||
assert!(!format.extensions().is_empty(), "{format:?}");
|
||||
for ext in format.extensions() {
|
||||
assert_eq!(FileFormat::from_extension(ext), Some(format), "{ext}");
|
||||
assert_eq!(FileFormat::from_extension(&format!(".{}", ext.to_ascii_uppercase())), Some(format), "{ext}");
|
||||
assert_eq!(FileFormat::from_path(format!("dir.x/Take 1.{ext}")), Some(format), "{ext}");
|
||||
}
|
||||
}
|
||||
for &format in FileFormat::WRITE {
|
||||
assert!(!format.write_extensions().is_empty(), "{format:?}");
|
||||
for ext in format.write_extensions() {
|
||||
assert_eq!(FileFormat::from_write_extension(ext), Some(format), "{ext}");
|
||||
assert_eq!(FileFormat::from_write_extension(&ext.to_ascii_uppercase()), Some(format), "{ext}");
|
||||
assert_eq!(FileFormat::from_extension(ext), Some(format), "{ext}: what is written reads back");
|
||||
}
|
||||
assert!(format.write_extensions().contains(&format.default_extension()), "{format:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_names_whose_readers_expect_the_encoder_output_are_writable() {
|
||||
assert_eq!(FileFormat::writable_extensions(), ["wav", "wave", "bwf", "aif", "aiff", "flac"]);
|
||||
for read_only in ["rf64", "aifc", "mp3", "ogg", "m4a", "mkv"] {
|
||||
assert!(FileFormat::from_extension(read_only).is_some(), "{read_only}");
|
||||
assert_eq!(FileFormat::from_write_extension(read_only), None, "{read_only}");
|
||||
}
|
||||
for unknown in ["xyz", "", "mid"] {
|
||||
assert_eq!(FileFormat::from_extension(unknown), None, "{unknown}");
|
||||
}
|
||||
assert_eq!(FileFormat::from_path("no-extension"), None);
|
||||
assert_eq!(FileFormat::Aiff.default_extension(), "aif");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod encode_format;
|
||||
mod decode_read_list;
|
||||
mod file_format;
|
||||
mod float_headroom;
|
||||
mod malformed;
|
||||
mod roundtrip;
|
||||
|
||||
@@ -9,9 +9,15 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-engine.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-time.workspace = true
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
# The launch flags (`args`): the web build has none.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
clap.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! The control and automation launch options, as clap types, so each flag and its environment
|
||||
//! variable are declared once for `soundcraft-cli` and the desktop app. A flag wins over its
|
||||
//! variable.
|
||||
//!
|
||||
//! Parse with [`blank_env_is_unset`] applied to the arguments, so an empty variable counts as unset,
|
||||
//! and pass a parse error through [`name_env_vars`], so a bad value names the variable it came from.
|
||||
//! [`conflict`] reports two arguments that can't go together, each named as it was given, for a
|
||||
//! conflict that depends on the mode, which clap's `conflicts_with` can't express.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use clap::error::{ContextKind, ContextValue, ErrorKind};
|
||||
use clap::parser::ValueSource;
|
||||
use clap::{Arg, ArgAction, ArgMatches, Args, Command};
|
||||
|
||||
use crate::ControlTarget;
|
||||
|
||||
/// The running app a client calls (`soundcraft-cli app`).
|
||||
#[derive(Args)]
|
||||
pub struct ControlClientArgs {
|
||||
/// The app's control port or host:port
|
||||
#[arg(long, value_name = "P", env = "SOUNDCRAFT_CONTROL_PORT", default_value = "7979", value_parser = control_target)]
|
||||
pub port: ControlTarget,
|
||||
}
|
||||
|
||||
/// The control server the desktop app starts. The same variable as [`ControlClientArgs`], so one
|
||||
/// export connects the two.
|
||||
#[derive(Args)]
|
||||
pub struct ControlServerArgs {
|
||||
/// Start the localhost JSON-lines control server on this port (0 picks a free port and prints
|
||||
/// it)
|
||||
// allow_negative_numbers makes `--control -1` a bad port rather than an unexpected argument.
|
||||
#[arg(long, value_name = "PORT", env = "SOUNDCRAFT_CONTROL_PORT", value_parser = control_target, allow_negative_numbers = true)]
|
||||
pub control: Option<ControlTarget>,
|
||||
}
|
||||
|
||||
/// The folders automation may touch. Once any root is given, files are confined to the roots, and
|
||||
/// a kind left out grants none of its access. No variable stands in for them: one left exported
|
||||
/// must not change what an agent may read or write.
|
||||
#[derive(Args)]
|
||||
pub struct AutomationArgs {
|
||||
/// A folder the agent's commands may read from; repeat for several
|
||||
#[arg(long, value_name = "DIR", action = ArgAction::Append)]
|
||||
pub automation_read_root: Vec<PathBuf>,
|
||||
/// A folder the agent's commands may write to; repeat for several
|
||||
#[arg(long, value_name = "DIR", action = ArgAction::Append)]
|
||||
pub automation_write_root: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// A [`ControlTarget`], for clap's `value_parser`: `soundcraft-cli app --port`, `mcp --connect`,
|
||||
/// `soundcraft --control`, and SOUNDCRAFT_CONTROL_PORT, which may carry spaces.
|
||||
pub fn control_target(s: &str) -> Result<ControlTarget, NotATarget> {
|
||||
let (host, port) = host_and_port(s.trim())?;
|
||||
Ok(ControlTarget { host: host.map(str::to_owned), port })
|
||||
}
|
||||
|
||||
/// `PORT` or `HOST:PORT`, split. An IPv6 host goes in brackets (`[::1]:7979`), as a socket address
|
||||
/// is written: in `::1:7979` the host and port can't be told apart.
|
||||
fn host_and_port(s: &str) -> Result<(Option<&str>, u16), NotATarget> {
|
||||
let (host, port) = match s.rsplit_once(':') {
|
||||
Some(("", _)) => return Err(NotATarget),
|
||||
Some((host, _)) if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) => return Err(NotATarget),
|
||||
Some((host, port)) => (Some(host), port),
|
||||
None => (None, s),
|
||||
};
|
||||
Ok((host, port.parse().map_err(|_| NotATarget)?))
|
||||
}
|
||||
|
||||
/// A [`control_target`] value that is neither a port nor `host:port`.
|
||||
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("expected a port, 0-65535, or host:port")]
|
||||
pub struct NotATarget;
|
||||
|
||||
/// `0.1.0 (linux x86_64)`: the workspace version and the platform the binary was built for. Every
|
||||
/// crate takes its version from the workspace, so the value is the same in both binaries. A
|
||||
/// `const` can't build it: `concat!` accepts only literals, not `std::env::consts`.
|
||||
pub fn long_version() -> &'static str {
|
||||
static LONG: OnceLock<String> = OnceLock::new();
|
||||
LONG.get_or_init(|| format!("{} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH))
|
||||
}
|
||||
|
||||
/// For `Command::mut_args`: an empty or blank variable counts as unset, so `SOUNDCRAFT_CONTROL_PORT=`
|
||||
/// turns a fallback off without `unset`. clap would pass the empty value to the parser.
|
||||
pub fn blank_env_is_unset(arg: Arg) -> Arg {
|
||||
let blank = arg.get_env().and_then(std::env::var_os).is_some_and(|v| v.to_str().is_some_and(|v| v.trim().is_empty()));
|
||||
if blank { arg.env(None::<&str>) } else { arg }
|
||||
}
|
||||
|
||||
/// clap names the flag when a value from its variable is bad or conflicts with another, as if the
|
||||
/// flag had been given: name the variable instead when the flag isn't among `args` (without the
|
||||
/// binary name; a first one naming a subcommand selects whose arguments to look in).
|
||||
pub fn name_env_vars(mut error: clap::Error, cmd: &Command, args: &[OsString]) -> clap::Error {
|
||||
// `--flag value` or `--flag=value`.
|
||||
let given =
|
||||
|long: &str| args.iter().filter_map(|a| a.to_str()?.strip_prefix("--")?.strip_prefix(long)).any(|r| r.is_empty() || r.starts_with('='));
|
||||
let sub = args.first().and_then(|name| cmd.find_subcommand(name)).unwrap_or(cmd);
|
||||
for kind in [ContextKind::InvalidArg, ContextKind::PriorArg] {
|
||||
let Some(ContextValue::String(shown)) = error.get(kind) else { continue };
|
||||
let Some(arg) = sub.get_arguments().find(|a| a.to_string() == *shown) else { continue };
|
||||
let (Some(long), Some(var)) = (arg.get_long(), arg.get_env()) else { continue };
|
||||
if !given(long) {
|
||||
error.insert(kind, ContextValue::String(var.to_string_lossy().into_owned()));
|
||||
}
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
/// clap's own conflict error when `m`, parsed by `cmd`, has both `a` and `b`, each named as it was
|
||||
/// given: the flag, or its variable.
|
||||
pub fn conflict(cmd: &mut Command, m: &ArgMatches, a: &str, b: &str) -> Result<(), clap::Error> {
|
||||
if m.value_source(a).is_none() || m.value_source(b).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let shown = |id: &str| {
|
||||
let arg = cmd.get_arguments().find(|arg| arg.get_id() == id);
|
||||
match (m.value_source(id), arg.and_then(Arg::get_env)) {
|
||||
(Some(ValueSource::EnvVariable), Some(var)) => var.to_string_lossy().into_owned(),
|
||||
_ => arg.map_or_else(|| id.to_owned(), |arg| arg.to_string()),
|
||||
}
|
||||
};
|
||||
let (shown_a, shown_b) = (shown(a), shown(b));
|
||||
let mut error = clap::Error::new(ErrorKind::ArgumentConflict).with_cmd(cmd);
|
||||
error.insert(ContextKind::InvalidArg, ContextValue::String(shown_a));
|
||||
error.insert(ContextKind::PriorArg, ContextValue::String(shown_b));
|
||||
error.insert(ContextKind::Usage, ContextValue::StyledStr(cmd.render_usage()));
|
||||
Err(error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn control_target_is_a_port_or_host_and_port() {
|
||||
let target = |host: Option<&str>, port| Ok(ControlTarget { host: host.map(str::to_owned), port });
|
||||
assert_eq!(control_target("7979"), target(None, 7979));
|
||||
assert_eq!(control_target(" 0 "), target(None, 0));
|
||||
assert_eq!(control_target("localhost:7979 "), target(Some("localhost"), 7979));
|
||||
assert_eq!(control_target("10.0.0.5:1"), target(Some("10.0.0.5"), 1));
|
||||
assert_eq!(control_target("[::1]:7979"), target(Some("[::1]"), 7979));
|
||||
for bad in ["", "x", "78787", ":7979", "host:", "host:x", "-1", "::1:7979", "[::1:7979"] {
|
||||
assert_eq!(control_target(bad), Err(NotATarget), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The control server listens on 127.0.0.1 only: `[::1]` is another address.
|
||||
#[test]
|
||||
fn a_loopback_target_is_a_port_127_0_0_1_or_localhost() {
|
||||
for loopback in ["50494", "127.0.0.1:7979", "localhost:7979"] {
|
||||
assert!(control_target(loopback).is_ok_and(|t| t.is_loopback()), "{loopback:?}");
|
||||
}
|
||||
for remote in ["10.0.0.5:7979", "example.com:7979", "[::1]:7979"] {
|
||||
assert!(control_target(remote).is_ok_and(|t| !t.is_loopback()), "{remote:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_target_displays_as_a_socket_address() {
|
||||
for (given, shown) in [("7979", "127.0.0.1:7979"), ("localhost:1", "localhost:1"), ("[::1]:7979", "[::1]:7979")] {
|
||||
assert_eq!(control_target(given).map(|t| t.to_string()), Ok(shown.to_owned()), "{given:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A command with one port flag backed by a variable no test sets.
|
||||
fn port_command() -> Command {
|
||||
Command::new("t").arg(Arg::new("port").long("port").env("SOUNDCRAFT_TEST_UNSET_PORT").value_parser(control_target))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_env_vars_names_the_variable_only_when_the_flag_was_not_given() {
|
||||
let mut cmd = port_command();
|
||||
let e = cmd.try_get_matches_from_mut(["t", "--port", "x"]).unwrap_err();
|
||||
let flag = name_env_vars(e, &cmd, &["--port".into(), "x".into()]);
|
||||
assert_eq!(flag.get(ContextKind::InvalidArg), Some(&ContextValue::String("--port <port>".into())));
|
||||
let e = cmd.try_get_matches_from_mut(["t", "--port=x"]).unwrap_err();
|
||||
// As the error from the variable would read: the flag is not among the arguments.
|
||||
let var = name_env_vars(e, &cmd, &[]);
|
||||
assert_eq!(var.get(ContextKind::InvalidArg), Some(&ContextValue::String("SOUNDCRAFT_TEST_UNSET_PORT".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_env_is_unset_keeps_an_unset_variable() {
|
||||
let arg = blank_env_is_unset(Arg::new("port").env("SOUNDCRAFT_TEST_UNSET_PORT"));
|
||||
assert_eq!(arg.get_env(), Some(std::ffi::OsStr::new("SOUNDCRAFT_TEST_UNSET_PORT")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_is_a_clap_usage_error_naming_both_flags() {
|
||||
let mut cmd = port_command().arg(Arg::new("connect").long("connect"));
|
||||
let m = cmd.try_get_matches_from_mut(["t", "--port", "1", "--connect", "2"]).unwrap();
|
||||
let e = conflict(&mut cmd, &m, "port", "connect").unwrap_err();
|
||||
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::ArgumentConflict, 2));
|
||||
assert!(e.to_string().starts_with("error: the argument '--port <port>' cannot be used with '--connect <connect>'\n\nUsage: t"), "{e}");
|
||||
let m = cmd.try_get_matches_from_mut(["t", "--port", "1"]).unwrap();
|
||||
assert!(conflict(&mut cmd, &m, "port", "connect").is_ok());
|
||||
}
|
||||
}
|
||||
+135
-53
@@ -5,14 +5,19 @@
|
||||
//! running app's `--control` port.
|
||||
//! - [`mcp::Server`]: a Model Context Protocol server over stdio (newline-delimited JSON-RPC 2.0)
|
||||
//! exposing SoundCraft as tools and resources.
|
||||
//! - [`args`]: the control and automation launch flags and their environment variables, as clap
|
||||
//! types.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod args;
|
||||
pub mod mcp;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_engine::file_access::{self, AutomationRoots};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::fmt;
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -79,64 +84,151 @@ impl Backend for Headless {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a running app's control channel listens: what `soundcraft-cli app --port`,
|
||||
/// `soundcraft-cli mcp --connect`, `soundcraft --control` and SOUNDCRAFT_CONTROL_PORT give, parsed
|
||||
/// once (`args::control_target`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ControlTarget {
|
||||
/// As given: a name, an IPv4 address, or an IPv6 address in brackets (`[::1]`). `None`:
|
||||
/// 127.0.0.1.
|
||||
pub host: Option<String>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl ControlTarget {
|
||||
/// Can the desktop app's control server, which listens on 127.0.0.1 only, serve this target?
|
||||
/// True for no host, `127.0.0.1` and `localhost`; `[::1]` is another address.
|
||||
pub fn is_loopback(&self) -> bool {
|
||||
self.host.as_deref().is_none_or(|host| ["127.0.0.1", "localhost"].contains(&host))
|
||||
}
|
||||
}
|
||||
|
||||
/// `host:port`, as a socket address is written.
|
||||
impl fmt::Display for ControlTarget {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}:{}", self.host.as_deref().unwrap_or("127.0.0.1"), self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a [`Remote`] request failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemoteError {
|
||||
/// The host name didn't resolve.
|
||||
#[error("{addr}: {source}")]
|
||||
Resolve { addr: String, source: io::Error },
|
||||
#[error("cannot reach SoundCraft at {addr}: no addresses found")]
|
||||
NoAddress { addr: String },
|
||||
#[error("cannot reach SoundCraft at {addr}: {source}")]
|
||||
Unreachable { addr: String, source: io::Error },
|
||||
/// Reading or writing the connection failed.
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
/// The app closed the connection before it replied.
|
||||
#[error("connection closed")]
|
||||
Closed,
|
||||
/// The reply isn't JSON.
|
||||
#[error(transparent)]
|
||||
Reply(#[from] serde_json::Error),
|
||||
/// The app's reply to a request it refused.
|
||||
#[error("{0}")]
|
||||
App(String),
|
||||
/// The connection was lost after a request that changes state went out: it isn't repeated,
|
||||
/// since the app may already have executed it.
|
||||
#[error("{source}; `{method}` may or may not have been executed, check the session before repeating it")]
|
||||
Unconfirmed { method: String, source: Box<RemoteError> },
|
||||
}
|
||||
|
||||
impl RemoteError {
|
||||
/// The connection went away, so a fresh one may succeed.
|
||||
fn is_lost_connection(&self) -> bool {
|
||||
match self {
|
||||
RemoteError::Closed => true,
|
||||
RemoteError::Io(e) => matches!(e.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::ConnectionReset | io::ErrorKind::ConnectionAborted),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Connection = (TcpStream, BufReader<TcpStream>);
|
||||
|
||||
/// A TCP client for a running app's control channel.
|
||||
pub struct Remote {
|
||||
/// The target as `host:port`, for connecting and for messages.
|
||||
addr: String,
|
||||
conn: Option<(TcpStream, BufReader<TcpStream>)>,
|
||||
conn: Option<Connection>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// `target` is a port (`7979`) or `host:port`.
|
||||
pub fn new(target: &str) -> Self {
|
||||
let addr = if target.contains(':') { target.to_string() } else { format!("127.0.0.1:{target}") };
|
||||
Remote { addr, conn: None, next_id: 1 }
|
||||
/// A client for the app at `target`; it connects on the first request.
|
||||
pub fn new(target: &ControlTarget) -> Self {
|
||||
Remote { addr: target.to_string(), conn: None, next_id: 1 }
|
||||
}
|
||||
|
||||
fn connect(&mut self) -> Result<(), String> {
|
||||
if self.conn.is_some() {
|
||||
return Ok(());
|
||||
/// The open connection, connecting first when there is none.
|
||||
fn connection(&mut self) -> Result<&mut Connection, RemoteError> {
|
||||
match self.conn {
|
||||
Some(ref mut conn) => Ok(conn),
|
||||
None => Ok(self.conn.insert(connect(&self.addr)?)),
|
||||
}
|
||||
let socks: Vec<_> = self.addr.to_socket_addrs().map_err(|e| format!("{}: {e}", self.addr))?.collect();
|
||||
let mut last = "no addresses found".to_string();
|
||||
let mut stream = None;
|
||||
for sock in &socks {
|
||||
match TcpStream::connect_timeout(sock, Duration::from_millis(800)) {
|
||||
Ok(s) => {
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
let s = stream.ok_or_else(|| format!("cannot reach SoundCraft at {}: {last}", self.addr))?;
|
||||
s.set_read_timeout(Some(Duration::from_secs(120))).map_err(|e| e.to_string())?;
|
||||
let r = s.try_clone().map_err(|e| e.to_string())?;
|
||||
self.conn = Some((s, BufReader::new(r)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One request/reply exchange. On failure the flag says whether the request line was already
|
||||
/// written to the socket (so the app may have executed it).
|
||||
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (String, bool)> {
|
||||
self.connect().map_err(|e| (e, false))?;
|
||||
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (RemoteError, bool)> {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
let (w, r) = self.conn.as_mut().ok_or_else(|| ("not connected".to_string(), false))?;
|
||||
writeln!(w, "{line}").map_err(|e| (e.to_string(), false))?;
|
||||
let (w, r) = self.connection().map_err(|e| (e, false))?;
|
||||
writeln!(w, "{line}").map_err(|e| (e.into(), false))?;
|
||||
let mut buf = String::new();
|
||||
r.read_line(&mut buf).map_err(|e| (e.to_string(), true))?;
|
||||
r.read_line(&mut buf).map_err(|e| (e.into(), true))?;
|
||||
if buf.is_empty() {
|
||||
return Err(("connection closed".into(), true));
|
||||
return Err((RemoteError::Closed, true));
|
||||
}
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.to_string(), true))?;
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.into(), true))?;
|
||||
if v.get("ok").and_then(Value::as_bool).unwrap_or(false) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err((v.get("error").and_then(Value::as_str).unwrap_or("error").to_string(), true))
|
||||
Err((RemoteError::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()), true))
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls `method`. A request lost with its connection is sent once more on a fresh one when
|
||||
/// it only reads state.
|
||||
pub fn request(&mut self, method: &str, params: Value) -> Result<Value, RemoteError> {
|
||||
match self.roundtrip(method, ¶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.
|
||||
@@ -146,19 +238,7 @@ fn is_read_only(method: &str) -> bool {
|
||||
|
||||
impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
match self.roundtrip(method, ¶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),
|
||||
}
|
||||
self.request(method, params).map_err(|e| e.to_string())
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
true
|
||||
@@ -185,23 +265,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remote_reports_unreachable() {
|
||||
let mut r = Remote::new("127.0.0.1:1");
|
||||
assert!(r.call("session.inspect", json!({})).is_err());
|
||||
let mut r = Remote::new(&ControlTarget { host: Some("127.0.0.1".into()), port: 1 });
|
||||
let e = r.request("session.inspect", json!({})).unwrap_err();
|
||||
assert!(matches!(e, RemoteError::Unreachable { .. }), "{e:?}");
|
||||
assert!(e.to_string().starts_with("cannot reach SoundCraft at 127.0.0.1:1: "), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_resolves_hostnames() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let mut r = Remote::new(&format!("localhost:{port}"));
|
||||
assert_eq!(r.connect(), Ok(()));
|
||||
let mut r = Remote::new(&ControlTarget { host: Some("localhost".into()), port });
|
||||
assert!(r.connection().is_ok());
|
||||
}
|
||||
|
||||
/// Fake app: accepts one connection per entry in `replies`, counts the request line it reads,
|
||||
/// then either drops the connection (`false`) or answers ok (`true`).
|
||||
fn fake_app(replies: Vec<bool>) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
fn fake_app(replies: Vec<bool>) -> (ControlTarget, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() };
|
||||
let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use crate::Backend;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
@@ -70,7 +71,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"bounce_mix",
|
||||
"Render the mix to an audio file. Returns peak dBFS and integrated LUFS.",
|
||||
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": ["wav", "aiff", "flac"]}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
|
||||
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": FileFormat::writable_extensions()}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
|
||||
&["path"],
|
||||
),
|
||||
tool("parity", "Feature-parity report against the incumbent's menu catalog.", json!({}), &[]),
|
||||
|
||||
@@ -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, RawParam};
|
||||
pub(crate) use instance::{Instance, NotePort, PortBuffers, PortInfo, ProcessInput, RawParam};
|
||||
|
||||
/// Most plugins one factory may report (hostile-input cap).
|
||||
const MAX_PLUGINS_PER_FACTORY: u32 = 1024;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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::*;
|
||||
use super::{
|
||||
Arc, CLAP_EXT_AUDIO_PORTS, CLAP_EXT_GUI, CLAP_EXT_LATENCY, CLAP_EXT_NOTE_PORTS, CLAP_EXT_PARAMS, CLAP_EXT_STATE, CLAP_PLUGIN_FACTORY_ID,
|
||||
CLAP_VERSION, CString, ClapError, EVENT_CAP, HashMap, MAX_FEATURES, MAX_PLUGINS_PER_FACTORY, Mutex, OnceLock, Path, PathBuf, PoisonError, c_void,
|
||||
clap_host, clap_plugin_audio_ports, clap_plugin_entry, clap_plugin_factory, clap_plugin_gui, clap_plugin_latency, clap_plugin_note_ports,
|
||||
clap_plugin_params, clap_plugin_state, clap_version_is_compatible, read_cstr, scan,
|
||||
};
|
||||
|
||||
// ---- loaded binaries -----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
CLAP_BEATTIME_FACTOR, CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE,
|
||||
CLAP_EVENT_TRANSPORT, CLAP_SECTIME_FACTOR, CLAP_TRANSPORT_HAS_BEATS_TIMELINE, CLAP_TRANSPORT_HAS_SECONDS_TIMELINE, CLAP_TRANSPORT_HAS_TEMPO,
|
||||
CLAP_TRANSPORT_HAS_TIME_SIGNATURE, CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_RECORDING, MAX_STATE_BYTES, Transport, c_void, clap_event_header,
|
||||
clap_event_midi, clap_event_note, clap_event_param_value, clap_event_transport, clap_input_events, clap_istream, clap_ostream,
|
||||
clap_output_events,
|
||||
};
|
||||
|
||||
// ---- events --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::events::ostream_write;
|
||||
use super::host::HostState;
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, CStr, CString, Mutex, Ordering, PoisonError, c_void, clap_ostream, clap_plugin, clap_plugin_gui, clap_plugin_state, clap_window,
|
||||
clap_window_handle, hostwin,
|
||||
};
|
||||
|
||||
// ---- editor (clap.gui) ---------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
AtomicBool, AtomicU64, CLAP_EXT_GUI, CLAP_EXT_LATENCY, CLAP_EXT_LOG, CLAP_EXT_PARAMS, CLAP_EXT_STATE, CLAP_LOG_ERROR, CLAP_LOG_FATAL,
|
||||
CLAP_LOG_WARNING, CStr, EDIT_CAP, Mutex, Ordering, c_char, c_void, clap_host, clap_host_gui, clap_host_latency, clap_host_log, clap_host_params,
|
||||
clap_host_state, clap_id, clap_log_severity, clap_param_clear_flags, clap_param_rescan_flags, read_cstr,
|
||||
};
|
||||
|
||||
// ---- host side -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ 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::*;
|
||||
use super::{
|
||||
Arc, CLAP_AUDIO_PORT_IS_MAIN, CLAP_PROCESS_ERROR, CStr, ClapError, MAX_PARAMS, MAX_PORT_CHANNELS, MAX_PORTS, Ordering, Transport, c_char, c_void,
|
||||
clap_audio_buffer, clap_audio_port_info, clap_host, clap_input_events, clap_istream, clap_note_port_info, clap_output_events, clap_param_info,
|
||||
clap_plugin, clap_plugin_audio_ports, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_process,
|
||||
read_name,
|
||||
};
|
||||
|
||||
// ---- audio buffers -------------------------------------------------------------------------
|
||||
|
||||
@@ -312,16 +317,8 @@ 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,
|
||||
frames: usize,
|
||||
steady_time: i64,
|
||||
inputs: &mut PortBuffers,
|
||||
outputs: &mut PortBuffers,
|
||||
events: &[Event],
|
||||
offset: u32,
|
||||
transport: Option<&Transport>,
|
||||
) -> Result<i32, ClapError> {
|
||||
pub fn process(&mut self, input: ProcessInput<'_>) -> Result<i32, ClapError> {
|
||||
let ProcessInput { frames, steady_time, inputs, outputs, events, offset, transport } = input;
|
||||
if !self.active {
|
||||
return Err(ClapError::Process("not active".into()));
|
||||
}
|
||||
@@ -386,6 +383,16 @@ 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `ClapPlugin`: a loaded CLAP instance behind the safe `soundcraft_dsp::Plugin` trait.
|
||||
|
||||
use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers};
|
||||
use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers, ProcessInput};
|
||||
use clap_sys::ext::note_ports::{CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI};
|
||||
use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport};
|
||||
use std::sync::Arc;
|
||||
@@ -231,7 +231,15 @@ impl Plugin for ClapPlugin {
|
||||
let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len());
|
||||
let offset = u32::try_from(start).unwrap_or(u32::MAX);
|
||||
let events = self.queue.get(..k).unwrap_or(&[]);
|
||||
let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset, self.transport.as_ref());
|
||||
let r = self.inst.process(ProcessInput {
|
||||
frames: n,
|
||||
steady_time: self.steady,
|
||||
inputs: &mut self.inputs,
|
||||
outputs: &mut self.outputs,
|
||||
events,
|
||||
offset,
|
||||
transport: self.transport.as_ref(),
|
||||
});
|
||||
self.queue.drain(..k);
|
||||
self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0));
|
||||
match (r, self.out_main) {
|
||||
|
||||
+29
-15
@@ -150,8 +150,9 @@ pub struct LoudnessMeter {
|
||||
sub_filled: usize,
|
||||
hist_count: Vec<u64>,
|
||||
hist_energy: Vec<f64>,
|
||||
tp_coefs: [[f32; TP_TAPS]; TP_PHASES],
|
||||
tp_hist: Vec<[f32; TP_TAPS]>,
|
||||
tp_coefs: [[f32; TP_PHASES]; TP_TAPS],
|
||||
tp_hist: Vec<[f32; 2 * TP_TAPS]>,
|
||||
tp_pos: Vec<usize>,
|
||||
true_peak: f32,
|
||||
max_momentary: f32,
|
||||
}
|
||||
@@ -197,7 +198,7 @@ impl LoudnessMeter {
|
||||
let weights = if channels == 6 { vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41] } else { vec![1.0; channels] };
|
||||
let [c1, c2] = k_weighting(sr);
|
||||
// Interpolation filter: windowed sinc at the original Nyquist, split into 4 phases.
|
||||
let mut tp_coefs = [[0.0f32; TP_TAPS]; TP_PHASES];
|
||||
let mut tp_coefs = [[0.0f32; TP_PHASES]; TP_TAPS];
|
||||
let total = TP_TAPS * TP_PHASES;
|
||||
let center = (total as f64 - 1.0) / 2.0;
|
||||
for i in 0..total {
|
||||
@@ -205,14 +206,14 @@ impl LoudnessMeter {
|
||||
let sinc = if t.abs() < 1e-12 { 1.0 } else { (std::f64::consts::PI * t).sin() / (std::f64::consts::PI * t) };
|
||||
let x = i as f64 / (total as f64 - 1.0);
|
||||
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * x).cos() + 0.08 * (4.0 * std::f64::consts::PI * x).cos();
|
||||
if let Some(slot) = tp_coefs.get_mut(i % TP_PHASES).and_then(|ph| ph.get_mut(i / TP_PHASES)) {
|
||||
if let Some(slot) = tp_coefs.get_mut(i / TP_PHASES).and_then(|tap| tap.get_mut(i % TP_PHASES)) {
|
||||
*slot = (sinc * w) as f32;
|
||||
}
|
||||
}
|
||||
for ph in &mut tp_coefs {
|
||||
let s: f32 = ph.iter().sum();
|
||||
for p in 0..TP_PHASES {
|
||||
let s: f32 = tp_coefs.iter().filter_map(|tap| tap.get(p)).sum();
|
||||
if s.abs() > 1e-6 {
|
||||
ph.iter_mut().for_each(|c| *c /= s);
|
||||
tp_coefs.iter_mut().filter_map(|tap| tap.get_mut(p)).for_each(|c| *c /= s);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
@@ -228,7 +229,8 @@ impl LoudnessMeter {
|
||||
hist_count: vec![0; HIST_BINS],
|
||||
hist_energy: vec![0.0; HIST_BINS],
|
||||
tp_coefs,
|
||||
tp_hist: vec![[0.0; TP_TAPS]; channels],
|
||||
tp_hist: vec![[0.0; 2 * TP_TAPS]; channels],
|
||||
tp_pos: vec![0; channels],
|
||||
true_peak: 0.0,
|
||||
max_momentary: MIN_DB,
|
||||
}
|
||||
@@ -257,7 +259,8 @@ impl LoudnessMeter {
|
||||
self.sub_filled = 0;
|
||||
self.hist_count.iter_mut().for_each(|v| *v = 0);
|
||||
self.hist_energy.iter_mut().for_each(|v| *v = 0.0);
|
||||
self.tp_hist.iter_mut().for_each(|h| *h = [0.0; TP_TAPS]);
|
||||
self.tp_hist.iter_mut().for_each(|h| *h = [0.0; 2 * TP_TAPS]);
|
||||
self.tp_pos.iter_mut().for_each(|p| *p = 0);
|
||||
self.true_peak = 0.0;
|
||||
self.max_momentary = MIN_DB;
|
||||
}
|
||||
@@ -285,13 +288,24 @@ impl LoudnessMeter {
|
||||
}
|
||||
|
||||
fn true_peak_sample(&mut self, c: usize, x: f32) {
|
||||
let Some(h) = self.tp_hist.get_mut(c) else { return };
|
||||
h.copy_within(0..TP_TAPS - 1, 1);
|
||||
h[0] = x;
|
||||
let (Some(h), Some(pos)) = (self.tp_hist.get_mut(c), self.tp_pos.get_mut(c)) else { return };
|
||||
*pos = (*pos + TP_TAPS - 1) % TP_TAPS;
|
||||
let p = *pos;
|
||||
for slot in [p, p + TP_TAPS] {
|
||||
if let Some(v) = h.get_mut(slot) {
|
||||
*v = x;
|
||||
}
|
||||
}
|
||||
let Some(w) = h.get(p..p + TP_TAPS).and_then(|w| <&[f32; TP_TAPS]>::try_from(w).ok()) else { return };
|
||||
let mut y = [-0.0f32; TP_PHASES];
|
||||
for (cs, s) in self.tp_coefs.iter().zip(w) {
|
||||
for (acc, k) in y.iter_mut().zip(cs) {
|
||||
*acc += k * s;
|
||||
}
|
||||
}
|
||||
let mut peak = x.abs();
|
||||
for ph in &self.tp_coefs {
|
||||
let y: f32 = ph.iter().zip(h.iter()).map(|(a, b)| a * b).sum();
|
||||
peak = peak.max(y.abs());
|
||||
for v in y {
|
||||
peak = peak.max(v.abs());
|
||||
}
|
||||
if peak > self.true_peak {
|
||||
self.true_peak = peak;
|
||||
|
||||
@@ -54,7 +54,24 @@ fn max_len(ch: &[Vec<f32>]) -> usize {
|
||||
|
||||
/// The largest finite sample magnitude in any channel.
|
||||
pub fn peak(ch: &[Vec<f32>]) -> f32 {
|
||||
ch.iter().flatten().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs()))
|
||||
ch.iter().map(|c| channel_peak(c)).fold(0.0f32, f32::max)
|
||||
}
|
||||
|
||||
/// Largest finite absolute sample of one channel (0 for none).
|
||||
pub fn channel_peak(x: &[f32]) -> f32 {
|
||||
let mut lanes = [0.0f32; 8];
|
||||
let (chunks, rest) = x.as_chunks::<8>();
|
||||
for c in chunks {
|
||||
for (m, v) in lanes.iter_mut().zip(c) {
|
||||
let a = v.abs();
|
||||
*m = if a < f32::INFINITY && a > *m { a } else { *m };
|
||||
}
|
||||
}
|
||||
for (m, v) in lanes.iter_mut().zip(rest) {
|
||||
let a = v.abs();
|
||||
*m = if a < f32::INFINITY && a > *m { a } else { *m };
|
||||
}
|
||||
lanes.into_iter().fold(0.0f32, f32::max)
|
||||
}
|
||||
|
||||
/// Peak-normalizes to `target_db` dBFS, either all channels together or each on its own.
|
||||
|
||||
@@ -502,7 +502,10 @@ pub struct Maximizer {
|
||||
sr: f32,
|
||||
la: usize,
|
||||
delay: Vec<DelayLine>,
|
||||
req: Vec<f32>,
|
||||
win: Vec<(u64, f32)>,
|
||||
win_head: usize,
|
||||
win_len: usize,
|
||||
clock: u64,
|
||||
smooth: Vec<f32>,
|
||||
idx: usize,
|
||||
sum: f64,
|
||||
@@ -526,7 +529,10 @@ impl Maximizer {
|
||||
sr: 48_000.0,
|
||||
la: 1,
|
||||
delay: Vec::new(),
|
||||
req: vec![1.0],
|
||||
win: vec![(0, 1.0)],
|
||||
win_head: 0,
|
||||
win_len: 0,
|
||||
clock: 0,
|
||||
smooth: vec![1.0],
|
||||
idx: 0,
|
||||
sum: 1.0,
|
||||
@@ -547,6 +553,40 @@ impl Maximizer {
|
||||
self.ceiling.snap();
|
||||
self.rel = time_coef(self.p.v(2), self.sr);
|
||||
}
|
||||
|
||||
fn window_min(&mut self, req: f32, la: usize) -> f32 {
|
||||
let cap = self.win.len();
|
||||
let t = self.clock;
|
||||
self.clock = t.saturating_add(1);
|
||||
if cap == 0 {
|
||||
return req.min(1.0);
|
||||
}
|
||||
while self.win_len > 0 && self.win.get(self.win_head).is_some_and(|&(at, _)| at.saturating_add(la as u64) <= t) {
|
||||
self.win_head = wrap(self.win_head + 1, cap);
|
||||
self.win_len -= 1;
|
||||
}
|
||||
if !req.is_nan() {
|
||||
while self.win_len > 0 && self.win.get(wrap(self.win_head + self.win_len - 1, cap)).is_some_and(|&(_, v)| v >= req) {
|
||||
self.win_len -= 1;
|
||||
}
|
||||
if self.win_len == cap {
|
||||
self.win_head = wrap(self.win_head + 1, cap);
|
||||
self.win_len -= 1;
|
||||
}
|
||||
if let Some(slot) = self.win.get_mut(wrap(self.win_head + self.win_len, cap)) {
|
||||
*slot = (t, req);
|
||||
self.win_len += 1;
|
||||
}
|
||||
}
|
||||
match self.win.get(self.win_head) {
|
||||
Some(&(_, v)) if self.win_len > 0 => v.min(1.0),
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap(i: usize, n: usize) -> usize {
|
||||
if i < n { i } else { i % n }
|
||||
}
|
||||
|
||||
impl Plugin for Maximizer {
|
||||
@@ -562,7 +602,7 @@ impl Plugin for Maximizer {
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
self.req = vec![1.0; self.la];
|
||||
self.win = vec![(0, 1.0); self.la];
|
||||
self.smooth = vec![1.0; self.la];
|
||||
self.drive.set_time(20.0, self.sr);
|
||||
self.ceiling.set_time(20.0, self.sr);
|
||||
@@ -572,7 +612,9 @@ impl Plugin for Maximizer {
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.delay.iter_mut().for_each(DelayLine::clear);
|
||||
self.req.iter_mut().for_each(|v| *v = 1.0);
|
||||
self.win_head = 0;
|
||||
self.win_len = 0;
|
||||
self.clock = 0;
|
||||
self.smooth.iter_mut().for_each(|v| *v = 1.0);
|
||||
self.sum = self.la as f64;
|
||||
self.held = 1.0;
|
||||
@@ -598,16 +640,13 @@ impl Plugin for Maximizer {
|
||||
peak = peak.max(x.abs());
|
||||
}
|
||||
let req = if peak > ceil { ceil / peak } else { 1.0 };
|
||||
if let Some(r) = self.req.get_mut(self.idx) {
|
||||
*r = req;
|
||||
}
|
||||
let hold = self.req.iter().copied().fold(1.0f32, f32::min);
|
||||
let hold = self.window_min(req, la);
|
||||
self.held = if hold < self.held { hold } else { hold + (self.held - hold) * self.rel };
|
||||
if let Some(s) = self.smooth.get_mut(self.idx) {
|
||||
self.sum += f64::from(self.held) - f64::from(*s);
|
||||
*s = self.held;
|
||||
}
|
||||
self.idx = (self.idx + 1) % la;
|
||||
self.idx = wrap(self.idx + 1, la);
|
||||
if self.idx == 0 {
|
||||
// Re-sum once per window to cancel floating-point drift.
|
||||
self.sum = self.smooth.iter().map(|&v| f64::from(v)).sum();
|
||||
|
||||
@@ -34,7 +34,8 @@ pub(crate) fn time_coef(ms: f32, sr: f32) -> f32 {
|
||||
/// Flushes denormals and non-finite values in feedback paths to zero.
|
||||
#[inline]
|
||||
pub(crate) fn flush(x: f32) -> f32 {
|
||||
if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 }
|
||||
let a = x.abs();
|
||||
if (a > 1.0e-25) & (a < f32::INFINITY) { x } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Number of frames a plugin may safely touch.
|
||||
|
||||
@@ -298,3 +298,94 @@ fn spectrum_edge_cases() {
|
||||
let m = magnitude_db(&[f32::NAN; 64], 64);
|
||||
assert!(m.iter().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
const PIN_LUFS: u32 = 3_215_227_935;
|
||||
const PIN_TRUE_PEAK: u32 = 3_162_870_746;
|
||||
fn noise(len: usize, seed: u64, amp: f32) -> Vec<f32> {
|
||||
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
((x >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0) * amp
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn bits_hash(chans: &[Vec<f32>]) -> u64 {
|
||||
chans.iter().flatten().fold(0xcbf2_9ce4_8422_2325u64, |h, v| (h ^ u64::from(v.to_bits())).wrapping_mul(0x0100_0000_01b3))
|
||||
}
|
||||
fn run_blocks(p: &mut dyn soundcraft_dsp::Plugin, input: &[Vec<f32>], block: usize) -> Vec<Vec<f32>> {
|
||||
let len = input[0].len();
|
||||
let mut out = vec![Vec::with_capacity(len); input.len()];
|
||||
let mut i = 0;
|
||||
while i < len {
|
||||
let n = block.min(len - i);
|
||||
let mut io: Vec<Vec<f32>> = input.iter().map(|c| c[i..i + n].to_vec()).collect();
|
||||
p.process(&mut io, n);
|
||||
for (o, c) in out.iter_mut().zip(io) {
|
||||
o.extend(c);
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
#[test]
|
||||
fn loudness_and_true_peak_do_not_depend_on_block_size() {
|
||||
let sr = 48_000.0;
|
||||
let len = 96_000;
|
||||
let l: Vec<f32> = sine(997.0, 0.5, sr, len).iter().zip(noise(len, 1, 0.3)).map(|(a, b)| a + b).collect();
|
||||
let r: Vec<f32> = sine(11_025.0, 0.7, sr, len).iter().zip(noise(len, 2, 0.2)).map(|(a, b)| a + b).collect();
|
||||
let io = [l, r];
|
||||
let run = |block: usize| {
|
||||
let mut m = LoudnessMeter::new(sr, 2);
|
||||
let mut i = 0;
|
||||
while i < len {
|
||||
let n = block.min(len - i);
|
||||
let chunk: Vec<Vec<f32>> = io.iter().map(|c| c[i..i + n].to_vec()).collect();
|
||||
m.process(&chunk, n);
|
||||
i += n;
|
||||
}
|
||||
(m.integrated_lufs().to_bits(), m.true_peak_db().to_bits(), m.momentary_lufs().to_bits())
|
||||
};
|
||||
let whole = run(len);
|
||||
for block in [1, 7, 1024, 4801] {
|
||||
assert_eq!(run(block), whole, "block {block}");
|
||||
}
|
||||
let near = |got: u32, pin: u32| (f32::from_bits(got) - f32::from_bits(pin)).abs() < 1e-3;
|
||||
assert!(near(whole.0, PIN_LUFS) && near(whole.1, PIN_TRUE_PEAK), "lufs {} dB, true peak {} dB", f32::from_bits(whole.0), f32::from_bits(whole.1));
|
||||
}
|
||||
#[test]
|
||||
fn peak_skips_non_finite_samples_at_any_length() {
|
||||
for len in 0..40 {
|
||||
let mut x = noise(len, len as u64 + 3, 0.9);
|
||||
for (at, v) in [(1, f32::NAN), (len.saturating_sub(1), f32::INFINITY), (len / 2, f32::NEG_INFINITY)] {
|
||||
if len > 3 {
|
||||
x[at] = v;
|
||||
}
|
||||
}
|
||||
let naive = x.iter().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
assert_eq!(offline::channel_peak(&x).to_bits(), naive.to_bits(), "len {len}");
|
||||
let y = noise(len, 99, 0.5);
|
||||
let both = y.iter().fold(naive, |m, v| m.max(v.abs()));
|
||||
assert_eq!(offline::peak(&[x, y]).to_bits(), both.to_bits(), "len {len}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn maximizer_and_plate_reverb_output_does_not_depend_on_block_size() {
|
||||
let len = 48_000;
|
||||
let loud = [noise(len, 5, 1.6), noise(len, 6, 1.2)];
|
||||
let mut burst = [noise(len, 7, 0.5), noise(len, 8, 0.5)];
|
||||
for c in &mut burst {
|
||||
c[4_800..].iter_mut().for_each(|v| *v = 0.0);
|
||||
}
|
||||
for (id, input) in [("maximizer", &loud), ("plate_reverb", &burst)] {
|
||||
let mut hashes = Vec::new();
|
||||
for block in [64, 1000, 4096] {
|
||||
let mut p = create(id).unwrap();
|
||||
p.prepare(48_000.0, 4096, 2);
|
||||
hashes.push(bits_hash(&run_blocks(p.as_mut(), input, block)));
|
||||
}
|
||||
assert!(hashes.iter().all(|h| *h == hashes[0]), "{id}: {hashes:x?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let Some(out) = args.first() else {
|
||||
eprintln!("usage: bounce_bench OUT.wav [reps] [JSON params]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let reps = args.get(1).and_then(|r| r.parse().ok()).unwrap_or(5usize);
|
||||
let mut params = args.get(2).and_then(|p| serde_json::from_str::<Value>(p).ok()).unwrap_or_else(|| json!({}));
|
||||
if let Some(m) = params.as_object_mut() {
|
||||
m.insert("path".into(), json!(out));
|
||||
}
|
||||
let mut e = soundcraft_engine::demo::demo_engine();
|
||||
let mut times = Vec::new();
|
||||
let mut last = Value::Null;
|
||||
for _ in 0..reps {
|
||||
let t0 = Instant::now();
|
||||
match e.execute("file.bounce_mix", ¶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");
|
||||
}
|
||||
@@ -310,7 +310,7 @@ pub fn recover(e: &mut Engine, left: &Leftover) -> Result<Vec<String>> {
|
||||
let mut r = Engine::default();
|
||||
let session = left.dir.join(SESSION);
|
||||
let links = left.origin.as_deref().map_or(session.as_path(), Path::new);
|
||||
let mut missing = open_session_linked(&mut r, &session.to_string_lossy(), links)?;
|
||||
let mut missing = open_session_linked(&mut r, &session, links)?;
|
||||
let mut s = r.session().clone();
|
||||
let unfinished = unfinished_takes(left, &s, &mut missing);
|
||||
if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) {
|
||||
@@ -535,7 +535,7 @@ mod tests {
|
||||
w.flush().unwrap();
|
||||
let file = std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join(SESSION)).collect::<Vec<_>>();
|
||||
let mut r = Engine::default();
|
||||
open_session(&mut r, &file[0].to_string_lossy()).unwrap();
|
||||
open_session(&mut r, &file[0]).unwrap();
|
||||
assert_eq!((file.len(), r.session().sources.len()), (1, 5));
|
||||
w.close(false);
|
||||
w.flush().unwrap();
|
||||
|
||||
@@ -92,11 +92,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
|
||||
let r = range_param(e, "file.bounce_stems", p)?;
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
let format = match str_param(p, "format") {
|
||||
Some("aiff") | Some("aif") => soundcraft_audio_io::FileFormat::Aiff,
|
||||
Some("flac") => soundcraft_audio_io::FileFormat::Flac,
|
||||
_ => soundcraft_audio_io::FileFormat::Wav,
|
||||
};
|
||||
let format = str_param(p, "format")
|
||||
.and_then(soundcraft_audio_io::FileFormat::from_write_extension)
|
||||
.unwrap_or(soundcraft_audio_io::FileFormat::Wav);
|
||||
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
|
||||
Some("16") => soundcraft_audio_io::BitDepth::Int16,
|
||||
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
|
||||
@@ -313,8 +311,9 @@ pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
let ext = std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
|
||||
let format_name = str_param(p, "format").unwrap_or(ext.as_str());
|
||||
let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| {
|
||||
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS))
|
||||
let format = soundcraft_audio_io::FileFormat::from_write_extension(format_name).ok_or_else(|| {
|
||||
let supported = soundcraft_audio_io::FileFormat::writable_extensions().join(", ");
|
||||
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {supported})"))
|
||||
})?;
|
||||
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
|
||||
Some("16") => soundcraft_audio_io::BitDepth::Int16,
|
||||
|
||||
@@ -232,7 +232,8 @@ mod tests {
|
||||
let buf = AudioBuffer { sample_rate: 96_000, channels: vec![tone.clone(), tone] };
|
||||
let mut e = Engine::default();
|
||||
let t = instrument_track(&mut e);
|
||||
for (ext, format) in [("flac", FileFormat::Flac), ("aif", FileFormat::Aiff)] {
|
||||
for format in [FileFormat::Flac, FileFormat::Aiff] {
|
||||
let ext = format.default_extension();
|
||||
let path = d.join(format!("tone.{ext}"));
|
||||
let opts = EncodeOptions { format, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
std::fs::write(&path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
|
||||
@@ -165,7 +165,7 @@ mod tests {
|
||||
let sess = dir.join("s.scraft");
|
||||
e.execute("session.save_as", &json!({"path": sess.to_string_lossy()})).unwrap();
|
||||
let mut e2 = Engine::default();
|
||||
let missing = crate::io::open_session(&mut e2, &sess.to_string_lossy()).unwrap();
|
||||
let missing = crate::io::open_session(&mut e2, &sess).unwrap();
|
||||
assert!(missing.is_empty(), "{missing:?}");
|
||||
assert_eq!(e2.session().tracks[0].clips()[0], clip);
|
||||
assert_eq!(e2.session().videos, e.session().videos);
|
||||
@@ -173,12 +173,12 @@ mod tests {
|
||||
std::fs::create_dir_all(dir.join("Video Files")).unwrap();
|
||||
std::fs::rename(&movie, dir.join("Video Files").join("picture.mp4")).unwrap();
|
||||
let mut e3 = Engine::default();
|
||||
assert!(crate::io::open_session(&mut e3, &sess.to_string_lossy()).unwrap().is_empty());
|
||||
assert!(crate::io::open_session(&mut e3, &sess).unwrap().is_empty());
|
||||
assert!(e3.session().videos[0].path.contains("Video Files"));
|
||||
// Movie gone: the session still opens and reports it.
|
||||
std::fs::remove_file(dir.join("Video Files").join("picture.mp4")).unwrap();
|
||||
let mut e4 = Engine::default();
|
||||
let missing = crate::io::open_session(&mut e4, &sess.to_string_lossy()).unwrap();
|
||||
let missing = crate::io::open_session(&mut e4, &sess).unwrap();
|
||||
assert!(missing.iter().any(|m| m.contains("picture.mp4")), "{missing:?}");
|
||||
assert!(e4.messages.iter().any(|m| m.contains("missing media")));
|
||||
assert_eq!(e4.session().tracks[0].clips().len(), 1);
|
||||
|
||||
@@ -48,16 +48,28 @@ struct Root {
|
||||
shown: PathBuf,
|
||||
}
|
||||
|
||||
/// A root [`AutomationRoots::new`] can't open: missing, unreadable, or not a folder.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("cannot open the automation {} root `{}`: {source}", kind(.access), .path.display())]
|
||||
pub struct RootError {
|
||||
access: Access,
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
}
|
||||
|
||||
fn kind(access: &Access) -> &'static str {
|
||||
match access {
|
||||
Access::Read => "read",
|
||||
Access::Write => "write",
|
||||
}
|
||||
}
|
||||
|
||||
impl Root {
|
||||
/// The folder at `path`, which must exist.
|
||||
fn open(path: &Path, access: Access) -> Result<Self, String> {
|
||||
let kind = match access {
|
||||
Access::Read => "read",
|
||||
Access::Write => "write",
|
||||
};
|
||||
fn open(path: &Path, access: Access) -> Result<Self, RootError> {
|
||||
let resolved = std::fs::canonicalize(path)
|
||||
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
|
||||
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
|
||||
.map_err(|source| RootError { access, path: path.to_owned(), source })?;
|
||||
Ok(Self { shown: plain(resolved.clone()), resolved })
|
||||
}
|
||||
}
|
||||
@@ -65,11 +77,11 @@ impl Root {
|
||||
impl AutomationRoots {
|
||||
/// The roots `read` and `write`, which must be existing folders (one given twice counts once);
|
||||
/// `None` when neither kind has any (nothing is confined then).
|
||||
pub fn new(read: &[&Path], write: &[&Path]) -> Result<Option<Arc<Self>>, String> {
|
||||
pub fn new(read: &[&Path], write: &[&Path]) -> Result<Option<Arc<Self>>, RootError> {
|
||||
if read.is_empty() && write.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let open = |paths: &[&Path], access: Access| -> Result<Vec<Root>, String> {
|
||||
let open = |paths: &[&Path], access: Access| -> Result<Vec<Root>, RootError> {
|
||||
let mut roots: Vec<Root> = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
let root = Root::open(path, access)?;
|
||||
@@ -354,15 +366,15 @@ mod tests {
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err();
|
||||
let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err().to_string();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(&[], &[&base.join("in/a.scraft")]).unwrap_err();
|
||||
let e = AutomationRoots::new(&[], &[&base.join("in/a.scraft")]).unwrap_err().to_string();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().to_string().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap();
|
||||
@@ -440,7 +452,7 @@ mod tests {
|
||||
let twice = AutomationRoots::new(&[&base.join("in"), &base.join("in/sub/..")], &[]).unwrap().unwrap();
|
||||
assert!(!twice.describe().contains(", ") && twice.describe().ends_with("; write none"), "{}", twice.describe());
|
||||
// A missing folder among several is an error.
|
||||
let e = AutomationRoots::new(&[&base.join("in"), &base.join("nope")], &[]).unwrap_err();
|
||||
let e = AutomationRoots::new(&[&base.join("in"), &base.join("nope")], &[]).unwrap_err().to_string();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
@@ -424,11 +424,7 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions,
|
||||
return Err(EngineError::BadParams("file.bounce_stems".into(), "bounces are limited to 4 hours".into()));
|
||||
}
|
||||
make_dir(dir)?;
|
||||
let ext = match opts.format {
|
||||
FileFormat::Aiff => "aif",
|
||||
FileFormat::Flac => "flac",
|
||||
_ => "wav",
|
||||
};
|
||||
let ext = opts.format.default_extension();
|
||||
let mut out = Vec::new();
|
||||
let stems: Vec<(TrackId, String)> = s
|
||||
.tracks
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::render::sanitize_name;
|
||||
use super::session::decode_source;
|
||||
use super::*;
|
||||
use super::{Arc, EncodeOptions, Engine, EngineError, FileFormat, OsStr, Path, Result, Session, SourceAudio, read_bundle, write_file};
|
||||
|
||||
// ─── Hosts without a filesystem (the browser) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::render::sanitize_name;
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AudioBuffer, ChannelFormat, Clip, Engine, EngineError, FileFormat, OsStr, Path, PathBuf, Result, Samples, Session, Source, SourceAudio,
|
||||
SourceId, TrackId, TrackKind, Value, file_found, file_key, json, read_file, stored_path,
|
||||
};
|
||||
|
||||
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
|
||||
pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{ChannelFormat, Clip, ClipContent, Engine, EngineError, Result, Samples, TrackId, TrackKind};
|
||||
|
||||
/// Most meter changes a MIDI file may bring into the tempo map; scores have a handful.
|
||||
pub(super) const MAX_MIDI_METERS: usize = 10_000;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::import::add_source;
|
||||
#[cfg(test)]
|
||||
use super::session::{save_copy, save_session};
|
||||
use super::*;
|
||||
use super::{Clip, Engine, EngineError, FileFormat, OsStr, PathBuf, Range, Samples, TrackId, read_file};
|
||||
|
||||
/// A recording's plan, made when it starts: each record-enabled track's take file, where the
|
||||
/// takes start on the timeline, the loop length they split into when loop recording, and the
|
||||
@@ -117,6 +117,7 @@ pub fn live_span(start: Samples, pass: Option<Samples>, punch: Option<Range>, le
|
||||
|
||||
#[cfg(test)]
|
||||
mod record_tests {
|
||||
use super::super::{AudioBuffer, Path, plan_takes};
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::import::add_source;
|
||||
#[cfg(test)]
|
||||
use super::midi::{MAX_MIDI_METERS, import_midi_bytes};
|
||||
use super::*;
|
||||
use super::{AudioBuffer, Clip, ClipContent, Engine, EngineError, FileFormat, Range, Result, Samples, Session, TrackId, TrackKind};
|
||||
|
||||
/// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the
|
||||
/// whole range, silence included; a range too long to render is refused rather than shortened.
|
||||
|
||||
@@ -2,7 +2,9 @@ use super::bundle::save_session_bundle;
|
||||
#[cfg(test)]
|
||||
use super::import::add_source;
|
||||
use super::import::{MAX_IMPORT_SAMPLES, at_rate, relink_videos};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, Engine, EngineError, OsStr, Path, PathBuf, Result, Session, SourceAudio, file_key, read_file, read_text, stored_path, write_session,
|
||||
};
|
||||
|
||||
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
|
||||
/// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written.
|
||||
@@ -34,16 +36,17 @@ pub fn save_session_copy(e: &Engine, path: &str) -> Result<usize> {
|
||||
}
|
||||
|
||||
/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal).
|
||||
pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
|
||||
open_session_linked(e, path, Path::new(path))
|
||||
pub fn open_session(e: &mut Engine, path: impl AsRef<Path>) -> Result<Vec<String>> {
|
||||
let path = path.as_ref();
|
||||
open_session_linked(e, path, path)
|
||||
}
|
||||
|
||||
/// [`open_session`], following the media paths in it as if they were stored in the session file
|
||||
/// `links` (crash recovery: the session written beside the autosave, its paths from the original).
|
||||
pub(crate) fn open_session_linked(e: &mut Engine, path: &str, links: &Path) -> Result<Vec<String>> {
|
||||
pub(crate) fn open_session_linked(e: &mut Engine, path: &Path, links: &Path) -> Result<Vec<String>> {
|
||||
let text = read_text(path)?;
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut missing = Vec::new();
|
||||
let mut files = DecodedFiles::new();
|
||||
for src in &s.sources {
|
||||
@@ -57,7 +60,9 @@ pub(crate) fn open_session_linked(e: &mut Engine, path: &str, links: &Path) -> R
|
||||
missing.extend(relink_videos(&mut s, links));
|
||||
s.folder = Some(dir);
|
||||
e.replace_session(s);
|
||||
e.path = Some(path.to_string());
|
||||
// Save writes back to `e.path`, which is text: a session at a name that isn't valid Unicode
|
||||
// opens as if never saved, rather than saving to a name with replacement characters.
|
||||
e.path = path.to_str().map(str::to_owned);
|
||||
for m in &missing {
|
||||
e.message(format!("missing media: {m}"));
|
||||
}
|
||||
@@ -89,7 +94,9 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std:
|
||||
|
||||
#[cfg(test)]
|
||||
mod save_tests {
|
||||
use super::super::{AudioBuffer, ChannelFormat, Clip, Engine, FileFormat, Range, TrackKind, back_up, copy_new};
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn autosave_copy_leaves_document_untouched_and_reuses_audio() {
|
||||
@@ -192,11 +199,11 @@ mod save_tests {
|
||||
assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5);
|
||||
std::fs::remove_file(dir.join("A/Audio Files/Audio 1_02.wav")).unwrap();
|
||||
let mut missing = Engine::default();
|
||||
open_session(&mut missing, &path("A/A.scraft")).unwrap();
|
||||
open_session(&mut missing, path("A/A.scraft")).unwrap();
|
||||
save_session(&mut missing, &path("B/Moved.scraft")).unwrap();
|
||||
let mut r = Engine::default();
|
||||
assert_eq!(
|
||||
open_session(&mut r, &path("B/Moved.scraft")).unwrap(),
|
||||
open_session(&mut r, path("B/Moved.scraft")).unwrap(),
|
||||
vec![dir.join("A").join("Audio Files").join("Audio 1_02.wav").display().to_string()]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -222,7 +229,7 @@ mod save_tests {
|
||||
Engine::default().execute("session.save_as", &json!({"path": dir.join("New/New.scraft")})).unwrap();
|
||||
std::fs::copy(&path, dir.join("Song")).unwrap();
|
||||
let mut bare = Engine::default();
|
||||
open_session(&mut bare, &dir.join("Song").to_string_lossy()).unwrap();
|
||||
open_session(&mut bare, dir.join("Song")).unwrap();
|
||||
bare.execute("session.save", &json!({})).unwrap();
|
||||
second.execute("session.save_as", &json!({"path": path, "replace": true})).unwrap();
|
||||
let r = reopen(&path);
|
||||
@@ -312,7 +319,9 @@ mod save_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod open_tests {
|
||||
use super::super::{AudioBuffer, ChannelFormat, Clip, EncodeOptions, Engine, FileFormat, SourceId, TrackKind};
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn a_file_named_by_many_sources_is_decoded_once() {
|
||||
@@ -425,7 +434,9 @@ mod open_tests {
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod pipe_tests {
|
||||
use super::super::{AudioBuffer, FileFormat};
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn a_pipe_named_as_media_is_refused_not_waited_on() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#[cfg(test)]
|
||||
use super::import::add_source;
|
||||
use super::*;
|
||||
use super::{Arc, AtomicBool, Clip, EngineError, Ordering, Range, Result, Samples, Session, TrackId};
|
||||
|
||||
/// Limit a range used for rendering to the track's material (plus a second), so a huge
|
||||
/// selection never renders hours of silence.
|
||||
@@ -244,7 +244,9 @@ fn non_silent(
|
||||
|
||||
#[cfg(test)]
|
||||
mod strip_tests {
|
||||
use super::super::{AudioBuffer, Engine, FileFormat, Value};
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A stereo track with `length`-long clips at `starts`, each playing a second of steady tone.
|
||||
fn tones(starts: &[Samples], length: Samples) -> (Engine, TrackId) {
|
||||
|
||||
@@ -137,3 +137,27 @@ fn dropping_an_unfinished_bounce_cancels_it_and_keeps_the_old_file() {
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
const PINNED: [[u32; 3]; 2] = [[0xc195_6706, 0xc093_0031, 0xc093_0031], [0xc162_e790, 0xbdcc_ccbf, 0xbdcc_ccbf]];
|
||||
|
||||
#[test]
|
||||
fn demo_bounce_levels_are_pinned() {
|
||||
let mut e = soundcraft_engine::demo::demo_engine();
|
||||
let dir = scratch("bounce-demo-pinned");
|
||||
let mut got = Vec::new();
|
||||
for (name, extra) in [("Mix.wav", serde_json::json!({})), ("Norm.wav", serde_json::json!({"normalize": true, "bit_depth": 16}))] {
|
||||
let path = dir.join(name);
|
||||
let mut p = serde_json::json!({"path": path, "start": 1_296_000, "end": 1_440_000});
|
||||
if let (Some(m), Some(x)) = (p.as_object_mut(), extra.as_object()) {
|
||||
m.extend(x.clone());
|
||||
}
|
||||
let r = e.execute("file.bounce_mix", &p).unwrap();
|
||||
assert!(std::fs::metadata(&path).unwrap().len() > 44, "{name} has audio");
|
||||
let level = |k: &str| r[k].as_f64().unwrap() as f32;
|
||||
got.push([level("lufs"), level("true_peak_db"), level("peak_db")]);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
for (g, pin) in got.iter().zip(PINNED) {
|
||||
assert!(g.iter().zip(pin).all(|(v, p)| (v - f32::from_bits(p)).abs() < 1e-3), "{got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{AutoParam, Samples, Session, TICKS_PER_QUARTER, Track, Transport, db_to_gain};
|
||||
|
||||
pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport {
|
||||
let sr = s.sample_rate;
|
||||
@@ -70,8 +70,20 @@ pub(crate) fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
|
||||
base
|
||||
}
|
||||
|
||||
pub(crate) fn lerp_gain(db0: f32, db1: f32, i: usize, n: usize) -> f32 {
|
||||
let g0 = db_to_gain(db0);
|
||||
let g1 = db_to_gain(db1);
|
||||
if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) }
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct GainRamp {
|
||||
g0: f32,
|
||||
g1: f32,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl GainRamp {
|
||||
pub(crate) fn new(db0: f32, db1: f32, n: usize) -> Self {
|
||||
GainRamp { g0: db_to_gain(db0), g1: db_to_gain(db1), n }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn at(&self, i: usize) -> f32 {
|
||||
if self.n == 0 { self.g0 } else { self.g0 + (self.g1 - self.g0) * (i as f32 / self.n as f32) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Bound, Clip, ClipContent, Plugin, Range, Samples, Session, db_to_gain};
|
||||
|
||||
/// Clip Effects: an EQ and a compressor per clip, configured from `edit.values`
|
||||
/// (`clip_fx.<clip>.eq.<param>`, `clip_fx.<clip>.comp.<param>`, `clip_fx.<clip>.gain`).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::strip::strip_channels;
|
||||
use super::*;
|
||||
use super::{Plugin, Session, TrackId, main_channels};
|
||||
|
||||
/// Slot index used for a track's instrument plugin in [`InstanceSpec`] and [`PreparedInstance`]
|
||||
/// (insert slots use their index).
|
||||
|
||||
+77
-40
@@ -59,7 +59,6 @@ use soundcraft_model::{
|
||||
use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER};
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Bound;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod automation;
|
||||
mod clips;
|
||||
@@ -72,7 +71,7 @@ mod routing;
|
||||
mod strip;
|
||||
mod threads;
|
||||
|
||||
use automation::{lerp_gain, pan_at, pan_segment, send_level, transport_at, volume_db_at};
|
||||
use automation::{GainRamp, pan_at, pan_segment, send_level, transport_at, volume_db_at};
|
||||
pub use instances::{INSTRUMENT_SLOT, InstanceSpec, NewInstance, PluginFailures, PreparedInstance, create_instance, instance_specs, is_third_party};
|
||||
pub use meter::StripMeter;
|
||||
use midi::sync_sample;
|
||||
@@ -82,7 +81,9 @@ pub use offline::{
|
||||
pub use pan::itu_stereo_fold;
|
||||
use pan::{PanSpec, apply_matrix, apply_matrix_span, build_matrix};
|
||||
use routing::{processing_order, structure_fingerprint};
|
||||
use strip::{BusBuf, LiveInput, SlotCtx, Strip, apply_params, contain_failure, note_insert_latency, process_strip, strip_channels, strip_format};
|
||||
use strip::{
|
||||
Block, BusBuf, LiveInput, SlotCtx, Strip, apply_params, contain_failure, note_insert_latency, process_strip, strip_channels, strip_format,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use strip::{DelayLine, MAX_LATENCY};
|
||||
use threads::run_strips;
|
||||
@@ -349,6 +350,44 @@ impl MixEngine {
|
||||
/// Render `frames` (≤ max_block) samples starting at `pos` into `out` (≥ 2 channels).
|
||||
pub fn render(&mut self, s: &Session, pos: Samples, frames: usize, out: &mut [Vec<f32>]) {
|
||||
let frames = frames.min(self.max_block);
|
||||
self.begin_block(s, pos, frames);
|
||||
let blk = Block { pos, frames, any_solo: s.tracks.iter().any(|t| t.mixer.solo && !t.inactive) };
|
||||
let order = std::mem::take(&mut self.order);
|
||||
// Phase 1: independent strips (audio, instrument, MIDI) in parallel.
|
||||
let mut work = std::mem::take(&mut self.work);
|
||||
work.clear();
|
||||
for &i in &order {
|
||||
let Some(t) = s.tracks.get(i).filter(|t| is_live(t) && !is_aux(t)) else { continue };
|
||||
if let Some(st) = self.strips.remove(&t.id) {
|
||||
work.push((i, st));
|
||||
}
|
||||
}
|
||||
for (_, st) in work.iter_mut() {
|
||||
if st.scratch.len() < self.max_block {
|
||||
st.scratch.resize(self.max_block, 0.0);
|
||||
}
|
||||
}
|
||||
let busses = std::mem::take(&mut self.busses);
|
||||
let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };
|
||||
run_strips(&mut work, |(i, st)| {
|
||||
if let Some(t) = s.tracks.get(*i) {
|
||||
process_strip(s, t, st, &busses, &live_in, blk);
|
||||
}
|
||||
});
|
||||
self.busses = busses;
|
||||
// Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait
|
||||
// for the slowest aux return. Aux latency has to be read before those outputs are routed,
|
||||
// or the first block (and a one-block bounce probe) still sees zero.
|
||||
self.compensate_delay(s, &mut work, blk);
|
||||
self.work = work;
|
||||
self.render_auxes(s, &order, blk);
|
||||
self.order = order;
|
||||
self.render_masters(s, blk);
|
||||
self.write_output(s, blk, out);
|
||||
}
|
||||
|
||||
/// Silence what a seek leaves ringing, sync to the session and clear the block's accumulators.
|
||||
fn begin_block(&mut self, s: &Session, pos: Samples, frames: usize) {
|
||||
if pos != self.last_end {
|
||||
// Seek: silence held notes and ringing plugins.
|
||||
for st in self.strips.values_mut() {
|
||||
@@ -369,35 +408,11 @@ impl MixEngine {
|
||||
for c in &mut self.main {
|
||||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive);
|
||||
let order = std::mem::take(&mut self.order);
|
||||
let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);
|
||||
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
|
||||
// Phase 1: independent strips (audio, instrument, MIDI) in parallel.
|
||||
let mut work = std::mem::take(&mut self.work);
|
||||
work.clear();
|
||||
for &i in &order {
|
||||
let Some(t) = s.tracks.get(i).filter(|t| live(t) && !is_aux(t)) else { continue };
|
||||
if let Some(st) = self.strips.remove(&t.id) {
|
||||
work.push((i, st));
|
||||
}
|
||||
}
|
||||
for (_, st) in work.iter_mut() {
|
||||
if st.scratch.len() < self.max_block {
|
||||
st.scratch.resize(self.max_block, 0.0);
|
||||
}
|
||||
}
|
||||
let busses = std::mem::take(&mut self.busses);
|
||||
let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };
|
||||
run_strips(&mut work, |(i, st)| {
|
||||
if let Some(t) = s.tracks.get(*i) {
|
||||
process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo);
|
||||
}
|
||||
});
|
||||
self.busses = busses;
|
||||
// Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait
|
||||
// for the slowest aux return. Aux latency has to be read before those outputs are routed,
|
||||
// or the first block (and a one-block bounce probe) still sees zero.
|
||||
}
|
||||
|
||||
/// Delay compensation for the independent strips (`work`), then route them back into the engine.
|
||||
fn compensate_delay(&mut self, s: &Session, work: &mut Vec<(usize, Strip)>, blk: Block) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
let pdc = s.edit.delay_compensation;
|
||||
if pdc {
|
||||
for t in s.tracks.iter().filter(|t| is_aux(t) && !t.inactive) {
|
||||
@@ -427,7 +442,7 @@ impl MixEngine {
|
||||
self.aux_latency = lmax_a;
|
||||
self.aux_fed.clear();
|
||||
if pdc {
|
||||
for t in s.tracks.iter().filter(|t| is_aux(t) && live(t)) {
|
||||
for t in s.tracks.iter().filter(|t| is_aux(t) && is_live(t)) {
|
||||
if let Route::Bus(b) = &t.mixer.output
|
||||
&& !self.aux_fed.contains(b)
|
||||
{
|
||||
@@ -461,16 +476,20 @@ impl MixEngine {
|
||||
self.strips.insert(t.id, st);
|
||||
}
|
||||
}
|
||||
self.work = work;
|
||||
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
|
||||
// Phase 2: auxes in dependency order (they read busses).
|
||||
for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) {
|
||||
}
|
||||
|
||||
/// Phase 2: auxes in dependency order (they read busses).
|
||||
fn render_auxes(&mut self, s: &Session, order: &[usize], blk: Block) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
let pdc = s.edit.delay_compensation;
|
||||
let lmax_a = self.aux_latency;
|
||||
for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| is_live(t)).filter(|t| is_aux(t)) {
|
||||
let Some(mut st) = self.strips.remove(&t.id) else { continue };
|
||||
if st.scratch.len() < self.max_block {
|
||||
st.scratch.resize(self.max_block, 0.0);
|
||||
}
|
||||
let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };
|
||||
process_strip(s, t, &mut st, &self.busses, &live_in, pos, frames, any_solo);
|
||||
process_strip(s, t, &mut st, &self.busses, &live_in, blk);
|
||||
let ch = st.buf.len();
|
||||
st.align.set(if pdc { lmax_a.saturating_sub(st.latency) } else { 0 }, ch);
|
||||
st.align.process(&mut st.buf, frames);
|
||||
@@ -478,7 +497,11 @@ impl MixEngine {
|
||||
self.route_strip(t, &mut st, pos, frames);
|
||||
self.strips.insert(t.id, st);
|
||||
}
|
||||
self.order = order;
|
||||
}
|
||||
|
||||
/// Master faders process the main mix.
|
||||
fn render_masters(&mut self, s: &Session, blk: Block) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
// Master faders process the main mix.
|
||||
let transport = transport_at(s, pos, !self.monitor_only, self.recording);
|
||||
for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) {
|
||||
@@ -506,10 +529,11 @@ impl MixEngine {
|
||||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||||
let v0 = volume_db_at(t, pos);
|
||||
let v1 = volume_db_at(t, last);
|
||||
let ramp = GainRamp::new(v0, v1, frames.saturating_sub(1));
|
||||
for (ch, src) in strip.buf.iter().enumerate().take(self.main.len()) {
|
||||
if let Some(m) = self.main.get_mut(ch) {
|
||||
for i in 0..frames {
|
||||
let g = lerp_gain(v0, v1, i, frames.saturating_sub(1));
|
||||
let g = ramp.at(i);
|
||||
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
|
||||
*d = x * g;
|
||||
}
|
||||
@@ -521,6 +545,11 @@ impl MixEngine {
|
||||
m.plugin_failures = strip.failures;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the metronome and copy the main mix to `out`.
|
||||
fn write_output(&mut self, s: &Session, blk: Block, out: &mut [Vec<f32>]) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
// Metronome: summed after the master fader, so it reaches the speakers without passing
|
||||
// through any track, send or insert. Only the playback engine sets `metronome`.
|
||||
// Not while monitoring only (transport stopped): the position does not advance there, so the
|
||||
@@ -628,6 +657,14 @@ std::thread_local! {
|
||||
static AUDIO_THREAD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
fn is_live(t: &Track) -> bool {
|
||||
!(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca)
|
||||
}
|
||||
|
||||
fn is_aux(t: &Track) -> bool {
|
||||
matches!(t.kind, TrackKind::Aux | TrackKind::Folder)
|
||||
}
|
||||
|
||||
/// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of
|
||||
/// each bar. Settings come from `setup.click_countoff` (`click.*` values).
|
||||
mod click;
|
||||
|
||||
@@ -75,5 +75,5 @@ fn max_level(a: f32, b: f32) -> f32 {
|
||||
}
|
||||
|
||||
fn peak(x: &[f32]) -> f32 {
|
||||
x.iter().fold(0.0f32, |m, v| if v.is_finite() { m.max(v.abs()) } else { m })
|
||||
soundcraft_dsp::offline::channel_peak(x)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::clips::Key;
|
||||
use super::strip::{PluginSlot, Strip};
|
||||
use super::*;
|
||||
use super::{ClipContent, CtrlKind, Range, SampleSource, Samples, Session, SourceAudio, Track};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The track's MIDI Track Offset (`midi.offset.<id>` in the edit values), in samples. Looked up
|
||||
/// without formatting the key, since this runs on the audio thread every block.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::clips::{ClipFx, clip_fx_params, render_clip};
|
||||
use super::pan::itu_stereo_fold;
|
||||
use super::*;
|
||||
use super::{AutoParam, BusId, ChannelFormat, MixEngine, Range, Route, Samples, Session, TrackId, main_channels};
|
||||
|
||||
/// Ceiling on one in-memory offline render (samples summed over channels): 2^29 f32 = 2 GiB, about
|
||||
/// 93 minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory;
|
||||
|
||||
+195
-121
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{ChannelFormat, MAX_CHANNELS, PanLaw, Speaker, SpeakerPos, SurroundPan, SurroundParams, db_to_gain, gains};
|
||||
|
||||
/// Per-block routing gains: `m[src][dst]`.
|
||||
type Matrix = [[f32; MAX_CHANNELS]; MAX_CHANNELS];
|
||||
@@ -48,6 +48,22 @@ fn surround_params(sp: &SurroundPan, dx: f32) -> SurroundParams {
|
||||
SurroundParams { x: (sp.x + dx).clamp(-1.0, 1.0), y: sp.y, z: sp.z, divergence: sp.divergence, center: sp.center / 100.0 }
|
||||
}
|
||||
|
||||
/// One source → destination routing: channel counts and formats, plus how the source is panned.
|
||||
struct Link<'a> {
|
||||
src_fmt: ChannelFormat,
|
||||
ns: usize,
|
||||
dst_fmt: ChannelFormat,
|
||||
nd: usize,
|
||||
pan: &'a PanSpec<'a>,
|
||||
law: PanLaw,
|
||||
}
|
||||
|
||||
fn add_gain(m: &mut Matrix, s: usize, d: usize, g: f32) {
|
||||
if let Some(x) = m.get_mut(s).and_then(|r| r.get_mut(d)) {
|
||||
*x += g;
|
||||
}
|
||||
}
|
||||
|
||||
/// Routing gains from a source (`ns` channels of `src_fmt`) into a destination (`nd` channels of
|
||||
/// `dst_fmt`). See the crate docs for the rules.
|
||||
pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFormat, nd: usize, pan: &PanSpec<'_>, law: PanLaw) -> Matrix {
|
||||
@@ -56,11 +72,6 @@ pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFo
|
||||
if ns == 0 || nd == 0 {
|
||||
return m;
|
||||
}
|
||||
let set = |m: &mut Matrix, s: usize, d: usize, g: f32| {
|
||||
if let Some(x) = m.get_mut(s).and_then(|r| r.get_mut(d)) {
|
||||
*x += g;
|
||||
}
|
||||
};
|
||||
if nd == 1 {
|
||||
// Mono destination: the stereo routing summed at -6 dB.
|
||||
let st = build_matrix(src_fmt, ns, ChannelFormat::Stereo, 2, pan, law);
|
||||
@@ -69,131 +80,168 @@ pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFo
|
||||
}
|
||||
return m;
|
||||
}
|
||||
let src_sp = src_fmt.speakers();
|
||||
let link = Link { src_fmt, ns, dst_fmt, nd, pan, law };
|
||||
let src_amb = src_fmt.is_ambisonic() && ns >= 4;
|
||||
if nd == 2 && !dst_fmt.is_ambisonic() {
|
||||
match ns {
|
||||
1 => {
|
||||
let (gl, gr) = gains(pan.stereo[0], law);
|
||||
m[0][0] = gl;
|
||||
m[0][1] = gr;
|
||||
}
|
||||
2 if !src_amb => {
|
||||
let (a_l, a_r) = gains(pan.stereo[0], PanLaw::Zero);
|
||||
let (b_l, b_r) = gains(pan.stereo[1], PanLaw::Zero);
|
||||
m[0] = [0.0; MAX_CHANNELS];
|
||||
m[0][0] = a_l;
|
||||
m[0][1] = a_r;
|
||||
m[1][0] = b_l;
|
||||
m[1][1] = b_r;
|
||||
}
|
||||
_ if src_amb => {
|
||||
for (d, az) in [(0usize, -30.0f32), (1, 30.0)] {
|
||||
let g = soundcraft_dsp::pan::ambisonic_decode(az, 0.0, 2);
|
||||
for (c, gc) in g.iter().enumerate() {
|
||||
set(&mut m, c, d, *gc);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if src_sp.len() == ns => {
|
||||
for (s, sp) in src_sp.iter().enumerate() {
|
||||
let (l, r) = itu_stereo_fold(*sp);
|
||||
set(&mut m, s, 0, l);
|
||||
set(&mut m, s, 1, r);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unknown layout: first two channels to L/R, the rest to the centre.
|
||||
let h = std::f32::consts::FRAC_1_SQRT_2;
|
||||
m[0][0] = 1.0;
|
||||
m[1][1] = 1.0;
|
||||
for row in m.iter_mut().take(ns).skip(2) {
|
||||
row[0] = h;
|
||||
row[1] = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
return m;
|
||||
return stereo_dest(&link, src_amb);
|
||||
}
|
||||
if ns > 2 && src_fmt == dst_fmt && ns == nd {
|
||||
return identity_matrix(ns);
|
||||
}
|
||||
// Multichannel destination.
|
||||
let mut layout = [SpeakerPos::default(); MAX_CHANNELS];
|
||||
let nl = layout_of(dst_fmt, &mut layout);
|
||||
let layout = layout.get(..nl).unwrap_or(&[]);
|
||||
let dst_sp = dst_fmt.speakers();
|
||||
let dst_amb = dst_fmt.is_ambisonic();
|
||||
let find = |sp: Speaker| dst_sp.iter().position(|x| *x == sp).filter(|i| *i < nd);
|
||||
let mut g = [0.0f32; MAX_CHANNELS];
|
||||
if dst_amb {
|
||||
if src_amb || (src_fmt.is_ambisonic() && ns > 2) {
|
||||
return identity_matrix(ns.min(nd));
|
||||
}
|
||||
let mut enc = |m: &mut Matrix, s: usize, az: f32, el: f32, spread: f32| {
|
||||
soundcraft_dsp::pan::ambisonic_encode(az, el, spread, &mut g);
|
||||
for (d, gd) in g.iter().enumerate().take(nd) {
|
||||
set(m, s, d, *gd);
|
||||
}
|
||||
};
|
||||
match (ns, pan.surround) {
|
||||
(1 | 2, Some(sp)) => {
|
||||
for s in 0..ns {
|
||||
let dx = if ns == 2 { if s == 0 { -1.0 } else { 1.0 } } else { 0.0 };
|
||||
let p = surround_params(sp, dx);
|
||||
enc(&mut m, s, soundcraft_dsp::pan::puck_azimuth(&[], p.x, p.y), p.z * 90.0, p.divergence);
|
||||
}
|
||||
}
|
||||
(1, None) => enc(&mut m, 0, pan.stereo[0].clamp(-1.0, 1.0) * 30.0, 0.0, 0.0),
|
||||
(2, None) => {
|
||||
enc(&mut m, 0, -30.0, 0.0, 0.0);
|
||||
enc(&mut m, 1, 30.0, 0.0, 0.0);
|
||||
}
|
||||
_ => {
|
||||
for (s, sp) in src_sp.iter().enumerate().take(ns) {
|
||||
if !sp.is_lfe() {
|
||||
enc(&mut m, s, sp.azimuth(), sp.elevation(), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m;
|
||||
if dst_fmt.is_ambisonic() {
|
||||
return ambisonic_dest(&link, src_amb);
|
||||
}
|
||||
layout_dest(&link, src_amb)
|
||||
}
|
||||
|
||||
/// Routing into a stereo destination.
|
||||
fn stereo_dest(l: &Link<'_>, src_amb: bool) -> Matrix {
|
||||
let mut m = [[0.0f32; MAX_CHANNELS]; MAX_CHANNELS];
|
||||
let src_sp = l.src_fmt.speakers();
|
||||
match l.ns {
|
||||
1 => {
|
||||
let (gl, gr) = gains(l.pan.stereo[0], l.law);
|
||||
m[0][0] = gl;
|
||||
m[0][1] = gr;
|
||||
}
|
||||
2 if !src_amb => {
|
||||
let (a_l, a_r) = gains(l.pan.stereo[0], PanLaw::Zero);
|
||||
let (b_l, b_r) = gains(l.pan.stereo[1], PanLaw::Zero);
|
||||
m[0] = [0.0; MAX_CHANNELS];
|
||||
m[0][0] = a_l;
|
||||
m[0][1] = a_r;
|
||||
m[1][0] = b_l;
|
||||
m[1][1] = b_r;
|
||||
}
|
||||
_ if src_amb => {
|
||||
for (d, az) in [(0usize, -30.0f32), (1, 30.0)] {
|
||||
let g = soundcraft_dsp::pan::ambisonic_decode(az, 0.0, 2);
|
||||
for (c, gc) in g.iter().enumerate() {
|
||||
add_gain(&mut m, c, d, *gc);
|
||||
}
|
||||
}
|
||||
}
|
||||
ns if src_sp.len() == ns => {
|
||||
for (s, sp) in src_sp.iter().enumerate() {
|
||||
let (left, right) = itu_stereo_fold(*sp);
|
||||
add_gain(&mut m, s, 0, left);
|
||||
add_gain(&mut m, s, 1, right);
|
||||
}
|
||||
}
|
||||
ns => {
|
||||
// Unknown layout: first two channels to L/R, the rest to the centre.
|
||||
let h = std::f32::consts::FRAC_1_SQRT_2;
|
||||
m[0][0] = 1.0;
|
||||
m[1][1] = 1.0;
|
||||
for row in m.iter_mut().take(ns).skip(2) {
|
||||
row[0] = h;
|
||||
row[1] = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// `±1` offset spreading a stereo source's two channels left and right of the surround pan.
|
||||
fn spread_dx(ns: usize, s: usize) -> f32 {
|
||||
if ns != 2 {
|
||||
0.0
|
||||
} else if s == 0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Routing into an ambisonic destination.
|
||||
fn ambisonic_dest(l: &Link<'_>, src_amb: bool) -> Matrix {
|
||||
let mut m = [[0.0f32; MAX_CHANNELS]; MAX_CHANNELS];
|
||||
let (ns, nd) = (l.ns, l.nd);
|
||||
if src_amb || (l.src_fmt.is_ambisonic() && ns > 2) {
|
||||
return identity_matrix(ns.min(nd));
|
||||
}
|
||||
let src_sp = l.src_fmt.speakers();
|
||||
let mut g = [0.0f32; MAX_CHANNELS];
|
||||
let mut enc = |m: &mut Matrix, s: usize, az: f32, el: f32, spread: f32| {
|
||||
soundcraft_dsp::pan::ambisonic_encode(az, el, spread, &mut g);
|
||||
for (d, gd) in g.iter().enumerate().take(nd) {
|
||||
add_gain(m, s, d, *gd);
|
||||
}
|
||||
};
|
||||
match (ns, l.pan.surround) {
|
||||
(1 | 2, Some(sp)) => {
|
||||
for s in 0..ns {
|
||||
let p = surround_params(sp, spread_dx(ns, s));
|
||||
enc(&mut m, s, soundcraft_dsp::pan::puck_azimuth(&[], p.x, p.y), p.z * 90.0, p.divergence);
|
||||
}
|
||||
}
|
||||
(1, None) => enc(&mut m, 0, l.pan.stereo[0].clamp(-1.0, 1.0) * 30.0, 0.0, 0.0),
|
||||
(2, None) => {
|
||||
enc(&mut m, 0, -30.0, 0.0, 0.0);
|
||||
enc(&mut m, 1, 30.0, 0.0, 0.0);
|
||||
}
|
||||
_ => {
|
||||
for (s, sp) in src_sp.iter().enumerate().take(ns) {
|
||||
if !sp.is_lfe() {
|
||||
enc(&mut m, s, sp.azimuth(), sp.elevation(), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// A surround-panned mono or stereo source into a speaker layout, plus its LFE send.
|
||||
fn surround_into_layout(m: &mut Matrix, l: &Link<'_>, layout: &[SpeakerPos], sp: &SurroundPan) {
|
||||
let (ns, nd) = (l.ns, l.nd);
|
||||
let dst_sp = l.dst_fmt.speakers();
|
||||
let mut g = [0.0f32; MAX_CHANNELS];
|
||||
for s in 0..ns {
|
||||
soundcraft_dsp::pan::surround_gains(layout, &surround_params(sp, spread_dx(ns, s)), &mut g);
|
||||
for (d, gd) in g.iter().enumerate().take(nd) {
|
||||
add_gain(m, s, d, *gd);
|
||||
}
|
||||
if let Some(lfe) = dst_sp.iter().position(|x| *x == Speaker::Lfe).filter(|i| *i < nd)
|
||||
&& sp.lfe_db > -143.9
|
||||
{
|
||||
add_gain(m, s, lfe, db_to_gain(sp.lfe_db.min(12.0)) / ns as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Routing into a speaker layout other than stereo or ambisonic.
|
||||
fn layout_dest(l: &Link<'_>, src_amb: bool) -> Matrix {
|
||||
let mut m = [[0.0f32; MAX_CHANNELS]; MAX_CHANNELS];
|
||||
let (ns, nd) = (l.ns, l.nd);
|
||||
let src_sp = l.src_fmt.speakers();
|
||||
let mut layout = [SpeakerPos::default(); MAX_CHANNELS];
|
||||
let nl = layout_of(l.dst_fmt, &mut layout);
|
||||
let layout = layout.get(..nl).unwrap_or(&[]);
|
||||
let dst_sp = l.dst_fmt.speakers();
|
||||
if dst_sp.len() != nd {
|
||||
// No layout to pan on: channel for channel.
|
||||
return identity_matrix(ns.min(nd));
|
||||
}
|
||||
let find = |sp: Speaker| dst_sp.iter().position(|x| *x == sp).filter(|i| *i < nd);
|
||||
let (li, ri) = (find(Speaker::L), find(Speaker::R));
|
||||
match (ns, pan.surround) {
|
||||
(1 | 2, Some(sp)) if !src_amb => {
|
||||
for s in 0..ns {
|
||||
let dx = if ns == 2 { if s == 0 { -1.0 } else { 1.0 } } else { 0.0 };
|
||||
soundcraft_dsp::pan::surround_gains(layout, &surround_params(sp, dx), &mut g);
|
||||
for (d, gd) in g.iter().enumerate().take(nd) {
|
||||
set(&mut m, s, d, *gd);
|
||||
}
|
||||
if let Some(lfe) = find(Speaker::Lfe)
|
||||
&& sp.lfe_db > -143.9
|
||||
{
|
||||
set(&mut m, s, lfe, db_to_gain(sp.lfe_db.min(12.0)) / ns as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut g = [0.0f32; MAX_CHANNELS];
|
||||
match (ns, l.pan.surround) {
|
||||
(1 | 2, Some(sp)) if !src_amb => surround_into_layout(&mut m, l, layout, sp),
|
||||
(1, None) => {
|
||||
let (gl, gr) = gains(pan.stereo[0], law);
|
||||
if let (Some(l), Some(r)) = (li, ri) {
|
||||
set(&mut m, 0, l, gl);
|
||||
set(&mut m, 0, r, gr);
|
||||
let (gl, gr) = gains(l.pan.stereo[0], l.law);
|
||||
if let (Some(left), Some(right)) = (li, ri) {
|
||||
add_gain(&mut m, 0, left, gl);
|
||||
add_gain(&mut m, 0, right, gr);
|
||||
}
|
||||
}
|
||||
(2, None) if !src_amb => {
|
||||
let (a_l, a_r) = gains(pan.stereo[0], PanLaw::Zero);
|
||||
let (b_l, b_r) = gains(pan.stereo[1], PanLaw::Zero);
|
||||
if let (Some(l), Some(r)) = (li, ri) {
|
||||
set(&mut m, 0, l, a_l);
|
||||
set(&mut m, 0, r, a_r);
|
||||
set(&mut m, 1, l, b_l);
|
||||
set(&mut m, 1, r, b_r);
|
||||
let (a_l, a_r) = gains(l.pan.stereo[0], PanLaw::Zero);
|
||||
let (b_l, b_r) = gains(l.pan.stereo[1], PanLaw::Zero);
|
||||
if let (Some(left), Some(right)) = (li, ri) {
|
||||
add_gain(&mut m, 0, left, a_l);
|
||||
add_gain(&mut m, 0, right, a_r);
|
||||
add_gain(&mut m, 1, left, b_l);
|
||||
add_gain(&mut m, 1, right, b_r);
|
||||
}
|
||||
}
|
||||
_ if src_amb => {
|
||||
@@ -204,20 +252,19 @@ pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFo
|
||||
}
|
||||
let dec = soundcraft_dsp::pan::ambisonic_decode(sp.azimuth(), sp.elevation(), n_spk);
|
||||
for (c, gc) in dec.iter().enumerate() {
|
||||
set(&mut m, c, d, *gc);
|
||||
add_gain(&mut m, c, d, *gc);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if src_sp.len() == ns => {
|
||||
for (s, sp) in src_sp.iter().enumerate() {
|
||||
if let Some(d) = find(*sp) {
|
||||
set(&mut m, s, d, 1.0);
|
||||
} else if sp.is_lfe() {
|
||||
// No LFE in the destination: dropped.
|
||||
} else {
|
||||
add_gain(&mut m, s, d, 1.0);
|
||||
} else if !sp.is_lfe() {
|
||||
// (An LFE with no LFE in the destination is dropped.)
|
||||
soundcraft_dsp::pan::direction_gains(layout, sp.azimuth(), sp.elevation(), &mut g);
|
||||
for (d, gd) in g.iter().enumerate().take(nd) {
|
||||
set(&mut m, s, d, *gd);
|
||||
add_gain(&mut m, s, d, *gd);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,6 +298,33 @@ pub(crate) fn apply_matrix_span(src: &[Vec<f32>], span: std::ops::Range<usize>,
|
||||
if taps.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let end = span.end.min(out.len());
|
||||
let Some(dst) = out.get_mut(span.start..end) else { continue };
|
||||
let lane = |s: usize| src.get(s).and_then(|c| c.get(span.start..end));
|
||||
match *taps {
|
||||
[(s0, g0)] => {
|
||||
if let Some(x0) = lane(s0) {
|
||||
for (o, a) in dst.iter_mut().zip(x0) {
|
||||
let mut acc = 0.0f32;
|
||||
acc += a * gain * g0;
|
||||
*o += acc;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
[(s0, g0), (s1, g1)] => {
|
||||
if let (Some(x0), Some(x1)) = (lane(s0), lane(s1)) {
|
||||
for ((o, a), b) in dst.iter_mut().zip(x0).zip(x1) {
|
||||
let mut acc = 0.0f32;
|
||||
acc += a * gain * g0;
|
||||
acc += b * gain * g1;
|
||||
*o += acc;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for (i, o) in out.iter_mut().enumerate().take(span.end).skip(span.start) {
|
||||
let mut acc = 0.0f32;
|
||||
for &(s, g) in taps {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use super::{BusId, Route, Session, Track, TrackKind};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Hash of everything `sync`/`processing_order` depend on (tracks, kinds, routing, plugins, sends).
|
||||
pub(crate) fn structure_fingerprint(s: &Session) -> usize {
|
||||
@@ -153,6 +155,7 @@ pub(crate) fn receives_solo(s: &Session, t: &Track) -> bool {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod processing_order_tests {
|
||||
use super::*;
|
||||
use soundcraft_model::ChannelFormat;
|
||||
use soundcraft_model::SendSlot;
|
||||
|
||||
#[test]
|
||||
|
||||
+176
-138
@@ -1,10 +1,15 @@
|
||||
use super::automation::{lerp_gain, transport_at, volume_db_at};
|
||||
use super::automation::{GainRamp, transport_at, volume_db_at};
|
||||
use super::clips::{ClipFx, clip_fx_params, render_clip};
|
||||
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance, is_third_party};
|
||||
use super::midi::{chase_controllers, schedule_notes};
|
||||
use super::pan::{PanSpec, apply_matrix, build_matrix, identity_matrix};
|
||||
use super::routing::receives_solo;
|
||||
use super::*;
|
||||
use super::{
|
||||
AutoParam, BusId, ChannelFormat, MAX_CHANNELS, PanLaw, Plugin, Range, Route, Samples, Session, SourceAudio, Track, TrackId, TrackKind, Transport,
|
||||
db_to_gain,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A bus: its format and one buffer per channel.
|
||||
pub(crate) struct BusBuf {
|
||||
@@ -199,131 +204,130 @@ pub(crate) struct LiveInput<'a> {
|
||||
pub(crate) recording: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn process_strip(
|
||||
s: &Session,
|
||||
t: &Track,
|
||||
strip: &mut Strip,
|
||||
busses: &HashMap<BusId, BusBuf>,
|
||||
live: &LiveInput<'_>,
|
||||
pos: Samples,
|
||||
frames: usize,
|
||||
any_solo: bool,
|
||||
) {
|
||||
for c in strip.buf.iter_mut() {
|
||||
/// The block being rendered: where it starts, its length, and whether any track is soloed.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Block {
|
||||
pub(crate) pos: Samples,
|
||||
pub(crate) frames: usize,
|
||||
pub(crate) any_solo: bool,
|
||||
}
|
||||
|
||||
fn zero_buf(buf: &mut [Vec<f32>], frames: usize) {
|
||||
for c in buf.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
let transport = transport_at(s, pos, !live.monitor_only, live.recording);
|
||||
match t.kind {
|
||||
TrackKind::Audio if !live.input.is_empty() && (t.mixer.input_monitor || (t.mixer.record_arm && (live.monitor_only || live.recording))) => {
|
||||
// Input monitoring: the track hears its hardware input instead of its clips.
|
||||
let first = match &t.mixer.input {
|
||||
Route::Hardware(h) => {
|
||||
h.trim_start_matches("In ").split(['-', ' ']).next().and_then(|n| n.parse::<usize>().ok()).map_or(0, |n| n.saturating_sub(1))
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
let n_in = live.input.len().max(1);
|
||||
for (k, dst) in strip.buf.iter_mut().enumerate() {
|
||||
if let Some(src) = live.input.get((first + k) % n_in) {
|
||||
for (d, x) in dst.iter_mut().zip(src.iter()).take(frames) {
|
||||
*d = *x;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Input monitoring: the track hears its hardware input instead of its clips.
|
||||
fn monitor_input(t: &Track, strip: &mut Strip, live: &LiveInput<'_>, frames: usize) {
|
||||
let first = match &t.mixer.input {
|
||||
Route::Hardware(h) => {
|
||||
h.trim_start_matches("In ").split(['-', ' ']).next().and_then(|n| n.parse::<usize>().ok()).map_or(0, |n| n.saturating_sub(1))
|
||||
}
|
||||
TrackKind::Audio if live.monitor_only => {}
|
||||
TrackKind::Instrument | TrackKind::Midi if live.monitor_only => {}
|
||||
TrackKind::Audio => {
|
||||
let block = Range { start: pos, end: pos.saturating_add(frames as i64) };
|
||||
for clip in t.clips() {
|
||||
if clip.muted || !clip.is_audio() || !clip.range().overlaps(&block) {
|
||||
continue;
|
||||
}
|
||||
match clip_fx_params(s, clip.id.0) {
|
||||
None => render_clip(s, clip, pos, frames, &mut strip.buf, &mut strip.scratch),
|
||||
Some(params) => {
|
||||
let nch = strip.buf.len();
|
||||
if strip.clipbuf.len() != nch || strip.clipbuf.first().map_or(0, Vec::len) < frames {
|
||||
strip.clipbuf = vec![vec![0.0; frames.max(strip.scratch.len())]; nch];
|
||||
}
|
||||
for c in strip.clipbuf.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch);
|
||||
let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch));
|
||||
fx.configure(params);
|
||||
fx.process(&mut strip.clipbuf, frames);
|
||||
for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) {
|
||||
for (x, y) in d.iter_mut().zip(c.iter()).take(frames) {
|
||||
*x += *y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
let n_in = live.input.len().max(1);
|
||||
for (k, dst) in strip.buf.iter_mut().enumerate() {
|
||||
if let Some(src) = live.input.get((first + k) % n_in) {
|
||||
for (d, x) in dst.iter_mut().zip(src.iter()).take(frames) {
|
||||
*d = *x;
|
||||
}
|
||||
}
|
||||
TrackKind::Aux | TrackKind::Folder => {
|
||||
if let Route::Bus(b) = &t.mixer.input
|
||||
&& let Some(src) = busses.get(b)
|
||||
{
|
||||
let (ns, nd) = (src.buf.len(), strip.buf.len());
|
||||
let dst_fmt = strip_format(t, nd);
|
||||
let m = if src.fmt == dst_fmt && ns == nd {
|
||||
identity_matrix(ns)
|
||||
} else if ns == 1 && nd == 2 {
|
||||
// A mono bus feeds both sides of a stereo aux.
|
||||
let mut m = [[0.0; MAX_CHANNELS]; MAX_CHANNELS];
|
||||
m[0][0] = 1.0;
|
||||
m[0][1] = 1.0;
|
||||
m
|
||||
} else {
|
||||
let stereo = if ns == 1 { [0.0, 0.0] } else { [-1.0, 1.0] };
|
||||
build_matrix(src.fmt, ns, dst_fmt, nd, &PanSpec { stereo, surround: None }, PanLaw::Minus3)
|
||||
};
|
||||
apply_matrix(&src.buf, frames, &mut strip.buf, &m, 1.0);
|
||||
}
|
||||
}
|
||||
TrackKind::Instrument | TrackKind::Midi => {
|
||||
if let Some(inst) = strip.instrument.as_mut().filter(|i| !i.failed) {
|
||||
// #152: push the instrument's stored parameter values into the live plugin
|
||||
// before processing, the same way inserts do below. Without this, the values
|
||||
// loaded from a session (or set via `mix.insert_state`) never reach the plugin
|
||||
// instance and the rendered audio matches the plugin's defaults — e.g. a saved
|
||||
// `level = -12 dB` sounds the same as `level = -6 dB`.
|
||||
if let Some(ins) = &t.instrument {
|
||||
apply_params(inst, ins, t, INSTRUMENT_SLOT, pos);
|
||||
}
|
||||
inst.plugin.set_transport(&transport);
|
||||
if std::mem::take(&mut strip.chase) {
|
||||
chase_controllers(s, t, pos, inst);
|
||||
}
|
||||
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
|
||||
if strip.buf.len() >= 2 {
|
||||
inst.plugin.process(&mut strip.buf, frames);
|
||||
} else if let Some(c0) = strip.buf.first_mut() {
|
||||
// Mono MIDI track: render the instrument in stereo and fold.
|
||||
let mut st = [std::mem::take(c0), std::mem::take(&mut strip.scratch)];
|
||||
inst.plugin.process(&mut st, frames);
|
||||
let [a, b] = st;
|
||||
*c0 = a;
|
||||
strip.scratch = b;
|
||||
for (x, y) in c0.iter_mut().zip(strip.scratch.iter()).take(frames) {
|
||||
*x = 0.5 * (*x + *y);
|
||||
}
|
||||
}
|
||||
if contain_failure(inst, &mut strip.buf, frames) {
|
||||
strip.failures.record(INSTRUMENT_SLOT);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let trim = db_to_gain(t.mixer.trim_db) * if t.mixer.phase_invert { -1.0 } else { 1.0 };
|
||||
if (trim - 1.0).abs() > f32::EPSILON {
|
||||
for c in strip.buf.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x *= trim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the audio clips overlapping the block into the strip, through their clip effects.
|
||||
fn render_audio_clips(s: &Session, t: &Track, strip: &mut Strip, blk: Block) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
let block = Range { start: pos, end: pos.saturating_add(frames as i64) };
|
||||
for clip in t.clips() {
|
||||
if clip.muted || !clip.is_audio() || !clip.range().overlaps(&block) {
|
||||
continue;
|
||||
}
|
||||
match clip_fx_params(s, clip.id.0) {
|
||||
None => render_clip(s, clip, pos, frames, &mut strip.buf, &mut strip.scratch),
|
||||
Some(params) => {
|
||||
let nch = strip.buf.len();
|
||||
if strip.clipbuf.len() != nch || strip.clipbuf.first().map_or(0, Vec::len) < frames {
|
||||
strip.clipbuf = vec![vec![0.0; frames.max(strip.scratch.len())]; nch];
|
||||
}
|
||||
zero_buf(&mut strip.clipbuf, frames);
|
||||
render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch);
|
||||
let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch));
|
||||
fx.configure(params);
|
||||
fx.process(&mut strip.clipbuf, frames);
|
||||
for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) {
|
||||
for (x, y) in d.iter_mut().zip(c.iter()).take(frames) {
|
||||
*x += *y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An aux or folder reads the bus its input is routed from.
|
||||
fn read_aux_bus(t: &Track, strip: &mut Strip, busses: &HashMap<BusId, BusBuf>, frames: usize) {
|
||||
if let Route::Bus(b) = &t.mixer.input
|
||||
&& let Some(src) = busses.get(b)
|
||||
{
|
||||
let (ns, nd) = (src.buf.len(), strip.buf.len());
|
||||
let dst_fmt = strip_format(t, nd);
|
||||
let m = if src.fmt == dst_fmt && ns == nd {
|
||||
identity_matrix(ns)
|
||||
} else if ns == 1 && nd == 2 {
|
||||
// A mono bus feeds both sides of a stereo aux.
|
||||
let mut m = [[0.0; MAX_CHANNELS]; MAX_CHANNELS];
|
||||
m[0][0] = 1.0;
|
||||
m[0][1] = 1.0;
|
||||
m
|
||||
} else {
|
||||
let stereo = if ns == 1 { [0.0, 0.0] } else { [-1.0, 1.0] };
|
||||
build_matrix(src.fmt, ns, dst_fmt, nd, &PanSpec { stereo, surround: None }, PanLaw::Minus3)
|
||||
};
|
||||
apply_matrix(&src.buf, frames, &mut strip.buf, &m, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the track's instrument into the strip.
|
||||
fn render_instrument(s: &Session, t: &Track, strip: &mut Strip, blk: Block, transport: &Transport) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
if let Some(inst) = strip.instrument.as_mut().filter(|i| !i.failed) {
|
||||
// #152: push the instrument's stored parameter values into the live plugin
|
||||
// before processing, the same way inserts do below. Without this, the values
|
||||
// loaded from a session (or set via `mix.insert_state`) never reach the plugin
|
||||
// instance and the rendered audio matches the plugin's defaults — e.g. a saved
|
||||
// `level = -12 dB` sounds the same as `level = -6 dB`.
|
||||
if let Some(ins) = &t.instrument {
|
||||
apply_params(inst, ins, t, INSTRUMENT_SLOT, pos);
|
||||
}
|
||||
inst.plugin.set_transport(transport);
|
||||
if std::mem::take(&mut strip.chase) {
|
||||
chase_controllers(s, t, pos, inst);
|
||||
}
|
||||
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
|
||||
if strip.buf.len() >= 2 {
|
||||
inst.plugin.process(&mut strip.buf, frames);
|
||||
} else if let Some(c0) = strip.buf.first_mut() {
|
||||
// Mono MIDI track: render the instrument in stereo and fold.
|
||||
let mut st = [std::mem::take(c0), std::mem::take(&mut strip.scratch)];
|
||||
inst.plugin.process(&mut st, frames);
|
||||
let [a, b] = st;
|
||||
*c0 = a;
|
||||
strip.scratch = b;
|
||||
for (x, y) in c0.iter_mut().zip(strip.scratch.iter()).take(frames) {
|
||||
*x = 0.5 * (*x + *y);
|
||||
}
|
||||
}
|
||||
if contain_failure(inst, &mut strip.buf, frames) {
|
||||
strip.failures.record(INSTRUMENT_SLOT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the active inserts in order; records their latency and the first gain reduction.
|
||||
fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
let mut gr = 0.0f32;
|
||||
let mut latency = 0usize;
|
||||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||||
@@ -332,7 +336,7 @@ pub(crate) fn process_strip(
|
||||
continue;
|
||||
}
|
||||
apply_params(slot, ins, t, i, pos);
|
||||
slot.plugin.set_transport(&transport);
|
||||
slot.plugin.set_transport(transport);
|
||||
slot.plugin.process(&mut strip.buf, frames);
|
||||
if contain_failure(slot, &mut strip.buf, frames) {
|
||||
strip.failures.record(i);
|
||||
@@ -345,15 +349,11 @@ pub(crate) fn process_strip(
|
||||
}
|
||||
strip.gr = gr;
|
||||
strip.latency = latency.min(MAX_LATENCY);
|
||||
let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);
|
||||
strip.muted = t.mixer.mute || soloed_out;
|
||||
if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {
|
||||
strip.pre.resize_with(strip.buf.len(), Vec::new);
|
||||
for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) {
|
||||
d.clear();
|
||||
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fader gain (volume, trim and VCA automation), ramped across the block.
|
||||
fn apply_fader(s: &Session, t: &Track, strip: &mut Strip, blk: Block) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
// Endpoints are the first and last sample of this block. The next block starts on its own
|
||||
// first sample, so a breakpoint there does not ramp the block before it.
|
||||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||||
@@ -380,16 +380,19 @@ pub(crate) fn process_strip(
|
||||
}
|
||||
}
|
||||
let (g0, g1) = if strip.muted { (-144.0, -144.0) } else { (v0.min(12.0), v1.min(12.0)) };
|
||||
let ramp = GainRamp::new(g0, g1, frames.saturating_sub(1));
|
||||
for c in strip.buf.iter_mut() {
|
||||
for i in 0..frames {
|
||||
if let Some(x) = c.get_mut(i) {
|
||||
*x *= lerp_gain(g0, g1, i, frames.saturating_sub(1));
|
||||
}
|
||||
for (i, x) in c.iter_mut().enumerate().take(frames) {
|
||||
*x *= ramp.at(i);
|
||||
}
|
||||
}
|
||||
// Track mute gates the audio and its pre-fader tap without splitting processor blocks.
|
||||
// Keep the existing curve interpolation, first-lane and mode policy, and render-position
|
||||
// clock. Processor/bus age and Master/VCA automation policy are separate concerns.
|
||||
}
|
||||
|
||||
/// Track mute gates the audio and its pre-fader tap without splitting processor blocks.
|
||||
/// Keep the existing curve interpolation, first-lane and mode policy, and render-position
|
||||
/// clock. Processor/bus age and Master/VCA automation policy are separate concerns.
|
||||
fn gate_mute_automation(t: &Track, strip: &mut Strip, blk: Block) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
if t.mixer.automation_mode.reads()
|
||||
&& let Some(lane) = t.lane(&AutoParam::Mute).filter(|lane| !lane.points.is_empty())
|
||||
{
|
||||
@@ -406,6 +409,41 @@ pub(crate) fn process_strip(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process_strip(s: &Session, t: &Track, strip: &mut Strip, busses: &HashMap<BusId, BusBuf>, live: &LiveInput<'_>, blk: Block) {
|
||||
let Block { pos, frames, any_solo } = blk;
|
||||
zero_buf(&mut strip.buf, frames);
|
||||
let transport = transport_at(s, pos, !live.monitor_only, live.recording);
|
||||
match t.kind {
|
||||
TrackKind::Audio if !live.input.is_empty() && (t.mixer.input_monitor || (t.mixer.record_arm && (live.monitor_only || live.recording))) => {
|
||||
monitor_input(t, strip, live, frames);
|
||||
}
|
||||
TrackKind::Audio if live.monitor_only => {}
|
||||
TrackKind::Instrument | TrackKind::Midi if live.monitor_only => {}
|
||||
TrackKind::Audio => render_audio_clips(s, t, strip, blk),
|
||||
TrackKind::Aux | TrackKind::Folder => read_aux_bus(t, strip, busses, frames),
|
||||
TrackKind::Instrument | TrackKind::Midi => render_instrument(s, t, strip, blk, &transport),
|
||||
_ => {}
|
||||
}
|
||||
let trim = db_to_gain(t.mixer.trim_db) * if t.mixer.phase_invert { -1.0 } else { 1.0 };
|
||||
if (trim - 1.0).abs() > f32::EPSILON {
|
||||
for c in strip.buf.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x *= trim);
|
||||
}
|
||||
}
|
||||
run_inserts(t, strip, blk, &transport);
|
||||
let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);
|
||||
strip.muted = t.mixer.mute || soloed_out;
|
||||
if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {
|
||||
strip.pre.resize_with(strip.buf.len(), Vec::new);
|
||||
for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) {
|
||||
d.clear();
|
||||
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
|
||||
}
|
||||
}
|
||||
apply_fader(s, t, strip, blk);
|
||||
gate_mute_automation(t, strip, blk);
|
||||
}
|
||||
|
||||
/// Silences a block the plugin filled with NaN or infinity and takes the plugin out until the
|
||||
/// next [`MixEngine::reset`]. True then.
|
||||
pub(crate) fn contain_failure(slot: &mut PluginSlot, buf: &mut [Vec<f32>], frames: usize) -> bool {
|
||||
|
||||
@@ -1150,3 +1150,40 @@ fn surround_upmix_and_downmix_between_layouts() {
|
||||
let e_ls = out[4][100].powi(2) + out[6][100].powi(2);
|
||||
assert!((e_ls - x(4).powi(2)).abs() < 1e-5, "Ls energy is preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_matrix_span_matches_the_plain_per_sample_sum() {
|
||||
let src: Vec<Vec<f32>> = (0..3).map(|c| (0..300).map(|i| ((i * 7 + c * 13) % 23) as f32 * 0.037 - 0.4).collect()).collect();
|
||||
let short = vec![src[0].clone(), src[1][..150].to_vec(), src[2].clone()];
|
||||
let mut layouts: Vec<Vec<(usize, usize, f32)>> =
|
||||
vec![vec![(0, 0, 0.7)], vec![(0, 0, 0.7), (1, 1, -0.3)], vec![(0, 0, 0.6), (1, 0, 0.45), (1, 1, 1.0)]];
|
||||
layouts.push(vec![(0, 0, 0.5), (1, 0, 0.25), (2, 0, 0.125), (2, 1, 0.9)]);
|
||||
for taps in &layouts {
|
||||
let mut m = [[0.0f32; MAX_CHANNELS]; MAX_CHANNELS];
|
||||
for &(s, d, g) in taps {
|
||||
m[s][d] = g;
|
||||
}
|
||||
for input in [&src, &short] {
|
||||
for span in [0..300, 17..211, 140..160, 250..400, 5..5] {
|
||||
let mut got: Vec<Vec<f32>> = (0..2).map(|d| (0..300).map(|i| (i + d) as f32 * 1e-3).collect()).collect();
|
||||
let mut want = got.clone();
|
||||
pan::apply_matrix_span(input, span.clone(), &mut got, &m, 0.8);
|
||||
for (d, out) in want.iter_mut().enumerate() {
|
||||
for (i, o) in out.iter_mut().enumerate().take(span.end).skip(span.start) {
|
||||
let mut acc = 0.0f32;
|
||||
for (s, row) in m.iter().enumerate().take(input.len()) {
|
||||
if row[d] != 0.0 {
|
||||
acc += input[s].get(i).copied().unwrap_or(0.0) * 0.8 * row[d];
|
||||
}
|
||||
}
|
||||
if m.iter().take(input.len()).any(|row| row[d] != 0.0) {
|
||||
*o += acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
let bits = |v: &Vec<Vec<f32>>| v.iter().flatten().map(|x| x.to_bits()).collect::<Vec<_>>();
|
||||
assert_eq!(bits(&got), bits(&want), "taps {taps:?}, span {span:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::AUDIO_THREAD;
|
||||
|
||||
/// Mark the calling thread as the realtime audio thread (`soundcraft_playback`'s cpal callbacks
|
||||
/// do, every call: one thread-local store). Strips [`MixEngine::render`] hands to worker threads
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AtomicBool, BLOCK, BTreeMap, Cmd, HashMap, HashSet, MAX_CHANNELS, MeterSnapshot, MixEngine, OLD_SESSIONS, Ordering, OutputTrouble, Plugin,
|
||||
PluginEditor, PluginStateReader, PluginStates, PreparedInstance, Range, Receiver, Samples, Sender, Session, Shared, SyncSender, TrackId,
|
||||
itu_stereo_fold, main_channels, record, sync_channel,
|
||||
};
|
||||
|
||||
/// How the audio output is doing, for the UI.
|
||||
#[derive(Default)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user