mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-02 06:56:53 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db51dd1739 | ||
|
|
d588e74667 | ||
|
|
7169dc492e | ||
|
|
85ffa1f53e | ||
|
|
444a716396 | ||
|
|
ba86e7eae7 | ||
|
|
54d36c2fcc | ||
|
|
382965153a | ||
|
|
2de7846d4b | ||
|
|
eb095cbe81 | ||
|
|
ef2f70eb35 | ||
|
|
e60a559596 | ||
|
|
4716141401 | ||
|
|
314be8740e | ||
|
|
675673346b | ||
|
|
ba3b191440 | ||
|
|
60cd0622f2 | ||
|
|
87836c3ec2 | ||
|
|
c896668d00 | ||
|
|
deceb0cd3a | ||
|
|
6446eb5598 | ||
|
|
a4e4a44bab | ||
|
|
99895de2c7 | ||
|
|
48f61d297e | ||
|
|
6e2da18e9e | ||
|
|
13b2488e95 | ||
|
|
6ef3300aba | ||
|
|
ad3147388f | ||
|
|
a13a4f5bd2 | ||
|
|
2d88f46f02 | ||
|
|
f28a10a5dd | ||
|
|
53bb43db46 | ||
|
|
b9fee48a75 | ||
|
|
948460a953 | ||
|
|
4179cf88e6 | ||
|
|
ca6271b082 | ||
|
|
4c31f2884b | ||
|
|
1964662a71 | ||
|
|
5288e42bc9 | ||
|
|
a97ce7556c | ||
|
|
13fe1df4b8 | ||
|
|
b2a386bc7a | ||
|
|
331b1820db | ||
|
|
966e253873 | ||
|
|
e8bc4758cc | ||
|
|
af4dd7ce4c | ||
|
|
f7b0805de6 | ||
|
|
cc02045a2d | ||
|
|
ddfe991a87 | ||
|
|
8da40bef96 | ||
|
|
90e8f60bad | ||
|
|
3bd3965c51 | ||
|
|
5a1324b8bc | ||
|
|
7902d59b47 | ||
|
|
552607cbee | ||
|
|
9f5f312051 | ||
|
|
92e74f6e00 | ||
|
|
ffb95ebcf3 | ||
|
|
440999a51f | ||
|
|
14a081304b | ||
|
|
072e548ec8 | ||
|
|
98fae00688 | ||
|
|
d2079bf09d | ||
|
|
5447c94cb6 | ||
|
|
3276358bef | ||
|
|
081f43b90a | ||
|
|
b4412929a7 | ||
|
|
ca27f0f8b4 | ||
|
|
5b69a11d89 | ||
|
|
81ab012724 | ||
|
|
5075da2f3e | ||
|
|
a62d955a39 | ||
|
|
af4970e388 | ||
|
|
9e768d659b | ||
|
|
4e9d778734 | ||
|
|
c2f14dff49 | ||
|
|
f39413033e | ||
|
|
19f4b695e2 | ||
|
|
17d553a7a7 | ||
|
|
57ae0a8b5d | ||
|
|
52083e07f1 | ||
|
|
b46584cc30 | ||
|
|
52b5cea05f | ||
|
|
228fd37ab7 | ||
|
|
60d18f225d | ||
|
|
ba0c6e381f | ||
|
|
bcb872f1a3 | ||
|
|
b119e3adce | ||
|
|
0d8dde8294 | ||
|
|
94fb7db790 | ||
|
|
3f5377c877 | ||
|
|
3b87c6dcc2 | ||
|
|
d2244d0765 | ||
|
|
e2809001cf | ||
|
|
92c925a1dc | ||
|
|
140e63fe09 | ||
|
|
c3cf5fe236 | ||
|
|
1ce93ab2ea | ||
|
|
dd1ac56430 | ||
|
|
fda41ecd12 | ||
|
|
6a10dfa19d | ||
|
|
672f5ab767 | ||
|
|
11ab6a999c | ||
|
|
b84d6c95d5 | ||
|
|
1d99907d85 | ||
|
|
57b3ae5357 | ||
|
|
2e6c519bef | ||
|
|
adc7c13a37 | ||
|
|
f5d7ca5d87 | ||
|
|
a6df32ffab | ||
|
|
7b654fd1a3 | ||
|
|
f16e312fbc | ||
|
|
986fb951df | ||
|
|
d9c293a92b | ||
|
|
5dbebaf617 | ||
|
|
5c8654b2d7 | ||
|
|
d97e5e7f16 | ||
|
|
a63b23adf7 | ||
|
|
0ede26f5bc | ||
|
|
09444326d5 | ||
|
|
b609172db6 | ||
|
|
65e5443975 | ||
|
|
1c284e3703 | ||
|
|
179f0c8c5d | ||
|
|
8ca6939981 | ||
|
|
60b2c07a70 | ||
|
|
e600e6bf64 | ||
|
|
90932afed0 | ||
|
|
50d0028e07 | ||
|
|
dcdcca4704 | ||
|
|
1a20190149 | ||
|
|
1ffc164082 | ||
|
|
c14dc61473 | ||
|
|
ceb3404c31 | ||
|
|
7c029c40ce | ||
|
|
ed0aed5ccb | ||
|
|
1aab8e449d | ||
|
|
c7a7cfa09b | ||
|
|
79c45a154d | ||
|
|
9adcc1f389 | ||
|
|
795f1cb6ef | ||
|
|
e631966cf7 | ||
|
|
45498c8268 | ||
|
|
afa1532aba | ||
|
|
0d0d0abc27 | ||
|
|
9dc26fa1ca | ||
|
|
75e5b90a86 | ||
|
|
7a9b3e38e1 | ||
|
|
9a99c82d2c | ||
|
|
0c7212854a | ||
|
|
84e591e1ca | ||
|
|
ee55acbc93 | ||
|
|
e294aa48c0 | ||
|
|
021caab499 | ||
|
|
fa77e03e16 | ||
|
|
ccd3fe7ca2 | ||
|
|
9f049ac7d0 | ||
|
|
7509b64c5c | ||
|
|
dab64379cb | ||
|
|
4f771268b0 | ||
|
|
8385ed94c6 | ||
|
|
8f23afe462 | ||
|
|
6d86a52310 | ||
|
|
9d5f5784d5 | ||
|
|
2e057309b9 |
@@ -19,6 +19,7 @@ jobs:
|
||||
# The shared workflow creates the draft release, which needs write access to contents.
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -26,8 +26,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
# 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
|
||||
with:
|
||||
app-name: soundcraft
|
||||
app-dir: SoundCraft
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft-apps folder, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
|
||||
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
|
||||
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
|
||||
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
|
||||
|
||||
## ⚠️ Assets: the absolute rule
|
||||
@@ -22,7 +20,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l <id>`), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names.
|
||||
|
||||
## Never crash
|
||||
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.**
|
||||
- No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API.
|
||||
- The same exception, same rules, for `soundcraft-vst3-host` (`crates/vst3-host`), the isolated unsafe helper crate for VST3 plugin FFI (COM vtables via the `vst3` bindings, module loading, host-side COM objects).
|
||||
@@ -67,7 +65,7 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc
|
||||
`cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit.
|
||||
|
||||
## Release
|
||||
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
|
||||
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/releasing.md`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry.
|
||||
@@ -81,11 +79,13 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
+8
-8
@@ -39,14 +39,14 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts
|
||||
|
||||
| Path | Author | Source | License |
|
||||
|---|---|---|---|
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
|
||||
| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 |
|
||||
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -3,3 +3,8 @@ Read `AGENTS.md`: it is the entry point for agents in this repo.
|
||||
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
|
||||
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
|
||||
before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+27
-26
@@ -1058,17 +1058,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1091,7 +1091,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -1099,7 +1099,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1120,13 +1120,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -4279,7 +4280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -4303,7 +4304,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4313,7 +4314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4325,7 +4326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
@@ -4338,7 +4339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4351,7 +4352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4368,7 +4369,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4379,7 +4380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4402,7 +4403,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4412,7 +4413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4428,7 +4429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4441,7 +4442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4456,7 +4457,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4466,7 +4467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -4495,7 +4496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4507,7 +4508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4523,7 +4524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6139,7 +6140,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6359,9 +6360,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.1"
|
||||
version = "9.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4"
|
||||
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
||||
+6
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -50,14 +50,15 @@ 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
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"] }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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).
|
||||
|
||||
@@ -340,7 +340,15 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+6
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
|
||||
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
|
||||
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change.
|
||||
- **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
|
||||
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
|
||||
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
|
||||
@@ -156,8 +157,8 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
|
||||
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
|
||||
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
|
||||
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
|
||||
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
|
||||
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the progress docs
|
||||
set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
|
||||
localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours,
|
||||
tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session.
|
||||
- **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media
|
||||
@@ -176,6 +177,7 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
|
||||
@@ -204,6 +206,6 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added |
|
||||
| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
|
||||
| 2026-10-10 | major | Progress docs restructured: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list |
|
||||
| 2026-10-06 | minor | Alpha and parity estimates |
|
||||
|
||||
@@ -154,7 +154,8 @@ pub(crate) struct RunArgs {
|
||||
pub(crate) struct ScriptArgs {
|
||||
/// The script to run
|
||||
pub(crate) file: PathBuf,
|
||||
/// Report a failing line and go on with the next
|
||||
/// Continue after command errors and finish requested output, then exit nonzero if any command failed.
|
||||
/// Malformed JSON stops immediately
|
||||
#[arg(long)]
|
||||
pub(crate) keep_going: bool,
|
||||
#[command(flatten)]
|
||||
|
||||
@@ -20,6 +20,8 @@ pub(crate) enum CliError {
|
||||
ScriptJson { path: PathBuf, line: usize, source: serde_json::Error },
|
||||
#[error("{}:{line}: {id}: {source}", path.display())]
|
||||
ScriptLine { path: PathBuf, line: usize, id: String, source: EngineError },
|
||||
#[error("script finished with command errors")]
|
||||
ScriptFailed,
|
||||
#[error("{}: {source}", path.display())]
|
||||
File { path: PathBuf, source: std::io::Error },
|
||||
#[error("{}: {source}", path.display())]
|
||||
|
||||
@@ -75,6 +75,7 @@ pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write
|
||||
let text = std::fs::read_to_string(path).map_err(|source| CliError::File { path: path.clone(), source })?;
|
||||
let mut e = load_engine(&args.engine, err)?;
|
||||
let keep = args.keep_going;
|
||||
let mut failed = false;
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
@@ -90,10 +91,12 @@ pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write
|
||||
return Err(failure);
|
||||
}
|
||||
outln!(err, "{failure}");
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
finish(&mut e, &args.engine, out)
|
||||
finish(&mut e, &args.engine, out)?;
|
||||
if failed { Err(CliError::ScriptFailed) } else { Ok(()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -238,13 +238,13 @@ 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.
|
||||
// Kept-going line errors stay bare; a final error summary supplies the failing status.
|
||||
let stop = invoke(&dir, &["script", "bad.txt"]);
|
||||
assert_eq!((stop.code, stop.stdout.as_str()), (1, ""));
|
||||
assert_eq!(stop.stderr, "error: bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
|
||||
let keep = invoke(&dir, &["script", "bad.txt", "--keep-going"]);
|
||||
assert_eq!((keep.code, keep.stdout.as_str()), (0, "track.new: {\"tracks\":[1]}\n"));
|
||||
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
|
||||
assert_eq!((keep.code, keep.stdout.as_str()), (1, "track.new: {\"tracks\":[1]}\n"));
|
||||
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\nerror: script finished with command errors\n");
|
||||
assert_fails(&invoke(&dir, &["script", "badjson.txt"]), "badjson.txt:1: bad JSON: key must be a string at line 1 column 2");
|
||||
}
|
||||
|
||||
@@ -515,10 +515,9 @@ fn mcp_roots_with_connect_are_a_usage_error() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
/// Native file paths round-trip on filesystems that accept non-Unicode names. macOS rejects
|
||||
/// these names with EILSEQ before the tool can write them. Run in-process: no shell is needed.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn non_unicode_file_names() {
|
||||
use std::ffi::OsString;
|
||||
@@ -543,8 +542,26 @@ fn non_unicode_file_names() {
|
||||
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);
|
||||
}
|
||||
|
||||
/// Engine output paths enter JSON, so invalid Unicode must be refused even on a filesystem
|
||||
/// that cannot create such a file (including macOS).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_session_outputs_are_rejected() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let dir = sandbox("nonunicode-outputs");
|
||||
for (flag, name) in [("--bounce", b"caf\xe9.wav".as_slice()), ("--save", b"caf\xe9.scraft".as_slice())] {
|
||||
let path = dir.join(OsString::from_vec(name.to_vec()));
|
||||
let (mut out, mut err) = (Vec::new(), Vec::new());
|
||||
let code = soundcraft_cli::run(vec!["run".into(), "--demo".into(), flag.into(), path.into_os_string()], &mut out, &mut err);
|
||||
let err = String::from_utf8_lossy(&err);
|
||||
assert_eq!(code, 1, "{err}");
|
||||
assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}");
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ mod closed_stdout;
|
||||
mod man_and_completions;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod run;
|
||||
mod script_status;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let sequence = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("soundcraft-script-status-{}-{stamp}-{sequence}", std::process::id()));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke(dir: &Path, args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).current_dir(dir).env("SOUNDCRAFT_NO_PREFS", "1").args(args).output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_going_runs_later_commands_and_finishes_but_reports_failure() {
|
||||
let dir = Scratch::new();
|
||||
std::fs::write(
|
||||
dir.0.join("audit.txt"),
|
||||
"audit.deliberately_invalid {}\ntrack.new {\"name\":\"After failure\",\"count\":1,\"format\":\"stereo\"}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let stop = invoke(&dir.0, &["script", "audit.txt", "--save", "stopped.scraft"]);
|
||||
assert_eq!(stop.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&stop.stderr).contains("audit.deliberately_invalid"));
|
||||
assert!(!String::from_utf8_lossy(&stop.stdout).contains("track.new:"));
|
||||
assert!(!dir.0.join("stopped.scraft").exists());
|
||||
|
||||
let keep = invoke(
|
||||
&dir.0,
|
||||
&["script", "audit.txt", "--keep-going", "--bounce", "after.wav", "--start", "0", "--end", "0.01", "--save", "after.scraft", "--inspect"],
|
||||
);
|
||||
assert_eq!(keep.status.code(), Some(1), "{}", String::from_utf8_lossy(&keep.stderr));
|
||||
assert!(String::from_utf8_lossy(&keep.stderr).contains("audit.deliberately_invalid"));
|
||||
let stdout = String::from_utf8_lossy(&keep.stdout);
|
||||
assert!(stdout.contains("track.new:") && stdout.contains("After failure"), "{stdout}");
|
||||
assert!(dir.0.join("after.wav").is_file());
|
||||
let info = invoke(&dir.0, &["info", "after.scraft"]);
|
||||
assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));
|
||||
assert!(String::from_utf8_lossy(&info.stdout).contains("After failure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_script_and_parsing_or_finish_failures_keep_their_status() {
|
||||
let dir = Scratch::new();
|
||||
std::fs::write(dir.0.join("good.txt"), "track.new {\"name\":\"Good\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
|
||||
let success = invoke(&dir.0, &["script", "good.txt", "--keep-going"]);
|
||||
assert!(success.status.success(), "{}", String::from_utf8_lossy(&success.stderr));
|
||||
|
||||
std::fs::write(dir.0.join("bad.txt"), "track.new {bad\ntrack.new {\"name\":\"Too late\"}\n").unwrap();
|
||||
let malformed = invoke(&dir.0, &["script", "bad.txt", "--keep-going", "--save", "malformed.scraft"]);
|
||||
assert_eq!(malformed.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&malformed.stderr).contains("bad JSON"));
|
||||
assert!(!String::from_utf8_lossy(&malformed.stdout).contains("track.new:"));
|
||||
assert!(!dir.0.join("malformed.scraft").exists());
|
||||
|
||||
std::fs::write(dir.0.join("parent"), "not a directory").unwrap();
|
||||
for (option, path) in [("--save", "parent/out.scraft"), ("--bounce", "parent/out.wav")] {
|
||||
let fail = invoke(&dir.0, &["script", "good.txt", "--keep-going", option, path]);
|
||||
assert_eq!(fail.status.code(), Some(1), "{option}: {}", String::from_utf8_lossy(&fail.stderr));
|
||||
assert!(String::from_utf8_lossy(&fail.stderr).contains("parent"));
|
||||
}
|
||||
|
||||
std::fs::write(dir.0.join("engine.txt"), "session.save {}\ntrack.new {\"name\":\"After engine failure\",\"count\":1}\n").unwrap();
|
||||
let fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "engine.scraft"]);
|
||||
assert_eq!(fail.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&fail.stderr).contains("session.save"));
|
||||
assert!(String::from_utf8_lossy(&fail.stdout).contains("track.new:"));
|
||||
assert!(dir.0.join("engine.scraft").is_file());
|
||||
|
||||
let both_fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "parent/out.scraft"]);
|
||||
assert_eq!(both_fail.status.code(), Some(1));
|
||||
let errors = String::from_utf8_lossy(&both_fail.stderr);
|
||||
assert!(errors.contains("session.save") && errors.contains("parent"), "{errors}");
|
||||
assert!(!errors.contains("script finished with command errors"), "{errors}");
|
||||
}
|
||||
@@ -185,7 +185,7 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(result) = craft_update::self_update::finish(&soundcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SoundCraft app icon (placeholder)
|
||||
|
||||
**A nightingale singing**, as an engraving-style head-and-shoulders portrait in the Crafting Apps'
|
||||
owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colour field, the bird
|
||||
owl-template framing: a full-bleed colour field, the bird
|
||||
in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off
|
||||
the bottom and right edges, three song arcs leaving the bill.
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -61,3 +58,14 @@ display_name = "Matt Smith-Burlage"
|
||||
|
||||
[people.ProdByBuddha]
|
||||
sync_github_name = true
|
||||
|
||||
[people.luigiPinna]
|
||||
real_name = "Luigi Pinna"
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.kk376]
|
||||
real_name = "Kushagra Kumar"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -13,7 +13,7 @@ serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-vst3-host: this crate
|
||||
# An isolated unsafe helper crate, like soundcraft-vst3-host: this crate
|
||||
# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere
|
||||
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||
[lints.rust]
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! values are the Audio Unit's own (plain) values, ranges and units, so no mapping is needed.
|
||||
//! Boolean parameters become toggles, indexed ones starting at 0 become choices.
|
||||
//!
|
||||
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate (craftrules never-crash):
|
||||
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate:
|
||||
//! `unsafe` is denied crate-wide and allowed only in the private `ffi` module (hand-written
|
||||
//! AudioToolbox / CoreFoundation declarations). The public API is safe, returns
|
||||
//! `Result`/`Option` and never panics. Limits of in-process hosting: an Audio Unit that itself
|
||||
|
||||
@@ -20,7 +20,7 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
|
||||
// Whether the packet table lists each packet's size and frame count (variable per packet).
|
||||
let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false);
|
||||
while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) {
|
||||
let (body, size) = (pos + 12, size as i64);
|
||||
let body = pos + 12;
|
||||
let used = match kind {
|
||||
b"desc" if size == 32 => {
|
||||
lists_sizes = be_u32(b, body + 16) == Some(0);
|
||||
@@ -28,12 +28,12 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
|
||||
described = true;
|
||||
Some(32)
|
||||
}
|
||||
b"data" if size == -1 => Some(4),
|
||||
b"data" if size == u64::MAX => Some(4),
|
||||
b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20),
|
||||
b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?,
|
||||
b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
|
||||
b"kuki" if size > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
|
||||
b"desc" | b"pakt" | b"chan" => None,
|
||||
_ => u64::try_from(size).ok(),
|
||||
_ => Some(size),
|
||||
};
|
||||
match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) {
|
||||
Some(next) if next < b.len() => pos = next,
|
||||
@@ -85,3 +85,14 @@ fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn rejects_cookie_size_above_signed_range() {
|
||||
let mut bytes = b"caff\0\x01\0\0kuki".to_vec();
|
||||
bytes.extend_from_slice(&(1u64 << 63).to_be_bytes());
|
||||
|
||||
assert!(super::check(&bytes, 0).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,10 +511,13 @@ pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
|
||||
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
|
||||
}
|
||||
|
||||
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
|
||||
/// The vendor string is skipped unread.
|
||||
/// Whether a vendor string or comment in a (little-endian) Vorbis comment block claims more than
|
||||
/// the block holds.
|
||||
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
|
||||
let Some(vendor) = le_u32(b, body) else { return false };
|
||||
let Some(vendor) = le_u32(b, body).filter(|_| body.saturating_add(4) <= end) else { return false };
|
||||
if vendor as usize > end - (body + 4) {
|
||||
return true;
|
||||
}
|
||||
let at = (body + 4).saturating_add(vendor as usize);
|
||||
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
|
||||
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
|
||||
|
||||
@@ -24,14 +24,14 @@ pub struct Peaks {
|
||||
struct Block {
|
||||
min: f32,
|
||||
max: f32,
|
||||
squares: f32,
|
||||
squares: f64,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
|
||||
|
||||
fn point(s: f32) -> Block {
|
||||
Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } }
|
||||
Block { min: s, max: s, squares: if s.is_finite() { f64::from(s) * f64::from(s) } else { 0.0 } }
|
||||
}
|
||||
|
||||
fn of(samples: &[f32]) -> Block {
|
||||
@@ -146,7 +146,7 @@ impl Peaks {
|
||||
.map(|w| {
|
||||
w.map(|(a, b)| {
|
||||
let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len()));
|
||||
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt()
|
||||
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f64).sqrt() as f32
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -215,6 +215,13 @@ mod tests {
|
||||
assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_large_samples_keep_finite_rms() {
|
||||
let samples = vec![1e20_f32; BLOCK];
|
||||
let peaks = Peaks::build(&samples);
|
||||
assert_eq!(peaks.rms_spans(&samples, 0.0, BLOCK as f64, 0.0, 1), vec![Some(1e20)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rms_spans_match_the_samples() {
|
||||
let x = signal(20_000);
|
||||
|
||||
@@ -580,13 +580,46 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
Ok((info, AudioBuffer { sample_rate, channels: planar }))
|
||||
}
|
||||
|
||||
/// Frames symphonia's AAC decoder yields for every packet; it supports no other frame length.
|
||||
const AAC_FRAMES_PER_PACKET: u64 = 1024;
|
||||
|
||||
/// Whether `track` of `reader` is AAC in MP4. The MP4 reader reports the stts total as the length,
|
||||
/// but the decoder yields a whole frame per packet (sample durations are not applied), so a last
|
||||
/// sample whose duration differs from a frame makes the two disagree.
|
||||
fn is_mp4_aac(reader: &dyn FormatReader, track_id: u32) -> bool {
|
||||
reader.format_info().short_name == "isomp4"
|
||||
&& reader.tracks().iter().find(|t| t.id == track_id).and_then(audio_params).is_some_and(|p| p.codec == CODEC_ID_AAC)
|
||||
}
|
||||
|
||||
/// The decoded length of AAC in MP4: count the track's packets without decoding them.
|
||||
fn mp4_aac_frames(reader: &mut dyn FormatReader, track_id: u32) -> Result<u64> {
|
||||
let mut frames: u64 = 0;
|
||||
loop {
|
||||
match reader.next_packet() {
|
||||
Ok(Some(packet)) if packet.track_id == track_id => frames = frames.saturating_add(AAC_FRAMES_PER_PACKET),
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => break,
|
||||
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(SymError::ResetRequired) => break,
|
||||
Err(e) => {
|
||||
demux_failure(frames, e)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
|
||||
guarded(|| {
|
||||
let reader = match open(bytes, detected)? {
|
||||
let mut reader = match open(bytes, detected)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => return Ok(info),
|
||||
};
|
||||
let (_, info) = info_of(reader.as_ref(), detected)?;
|
||||
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
|
||||
if info.frames > 0 && info.channels > 0 && is_mp4_aac(reader.as_ref(), track_id) {
|
||||
info.frames = mp4_aac_frames(reader.as_mut(), track_id)?;
|
||||
}
|
||||
if info.frames > 0 && info.channels > 0 {
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
@@ -342,7 +342,10 @@ pub(crate) fn read_channel_mask(b: &[u8]) -> Option<u32> {
|
||||
|
||||
fn put_fixed_str(out: &mut Vec<u8>, s: &str, len: usize) {
|
||||
let bytes = s.as_bytes();
|
||||
let n = bytes.len().min(len);
|
||||
let mut n = bytes.len().min(len);
|
||||
while !s.is_char_boundary(n) {
|
||||
n -= 1;
|
||||
}
|
||||
out.extend_from_slice(bytes.get(..n).unwrap_or_default());
|
||||
out.resize(out.len() + (len - n), 0);
|
||||
}
|
||||
@@ -500,6 +503,7 @@ pub(crate) fn header(
|
||||
) -> Result<Vec<u8>> {
|
||||
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
|
||||
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
|
||||
let byte_rate = sample_rate.checked_mul(u32::from(block_align)).ok_or_else(|| AudioError::Encode("WAV byte rate overflow".into()))?;
|
||||
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
|
||||
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
|
||||
@@ -508,7 +512,7 @@ pub(crate) fn header(
|
||||
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
|
||||
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
|
||||
fmt.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&block_align.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
if extensible {
|
||||
@@ -557,3 +561,14 @@ pub(crate) fn header(
|
||||
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::header;
|
||||
|
||||
#[test]
|
||||
fn rejects_byte_rate_overflow() {
|
||||
let result = header(2, 1_100_000_000, (16, false), None, None, 0, false);
|
||||
assert!(result.is_err(), "overflowing byte rate was accepted");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod decode_read_list;
|
||||
mod file_format;
|
||||
mod float_headroom;
|
||||
mod malformed;
|
||||
mod mp4_length;
|
||||
mod roundtrip;
|
||||
mod stream;
|
||||
mod wav_empty_data;
|
||||
|
||||
@@ -208,6 +208,70 @@ fn caf_sizes_beyond_the_file_are_errors_not_aborts() {
|
||||
assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets")));
|
||||
}
|
||||
|
||||
/// An EBML element: `id`, then `size` as an 8-byte vint (so any size up to 2^56 − 2 fits, and
|
||||
/// `u64::MAX` writes the reserved "unknown size"), then `body`.
|
||||
fn ebml(id: &[u8], size: u64, body: &[u8]) -> Vec<u8> {
|
||||
let size = if size == u64::MAX { 0x01FF_FFFF_FFFF_FFFF } else { 0x0100_0000_0000_0000 | size };
|
||||
[id, &size.to_be_bytes(), body].concat()
|
||||
}
|
||||
|
||||
/// The EBML header of a Matroska file.
|
||||
fn ebml_header() -> Vec<u8> {
|
||||
let doc_type = ebml(&[0x42, 0x82], 8, b"matroska");
|
||||
ebml(&[0x1A, 0x45, 0xDF, 0xA3], doc_type.len() as u64, &doc_type)
|
||||
}
|
||||
|
||||
/// #878: symphonia's Matroska reader takes element sizes from the file. With symphonia 0.5 a size
|
||||
/// far beyond the file was allocated up front and aborted the process (no `catch_unwind` can catch
|
||||
/// an allocation failure); since 0.6 such reads grow with the data actually read. Keep it that way
|
||||
/// for the header, for codec data and for block frames.
|
||||
#[test]
|
||||
fn matroska_sizes_beyond_the_file_are_errors_not_aborts() {
|
||||
let huge = 1u64 << 47;
|
||||
// The 30-byte file from the report: EBML header and DocType sizes of 2^48 − 1 and 2^47.
|
||||
let reported = [
|
||||
0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x42, 0x82, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6D,
|
||||
0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61,
|
||||
];
|
||||
let segment = |body: Vec<u8>| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat();
|
||||
let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat();
|
||||
let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info);
|
||||
// A valid 16-bit PCM track entry's children, followed by `extra`.
|
||||
let pcm = |extra: &[u8]| {
|
||||
let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat();
|
||||
[
|
||||
ebml(&[0xD7], 1, &[1]),
|
||||
ebml(&[0x73, 0xC5], 1, &[1]),
|
||||
ebml(&[0x83], 1, &[2]),
|
||||
ebml(&[0x86], 13, b"A_PCM/INT/LIT"),
|
||||
ebml(&[0xE1], audio.len() as u64, &audio),
|
||||
extra.to_vec(),
|
||||
]
|
||||
.concat()
|
||||
};
|
||||
// That track with a CodecPrivate claiming 2^46 bytes, inside parents that claim even more: the
|
||||
// segment is valid up to that read, which runs out of file.
|
||||
let track = ebml(&[0xAE], huge, &pcm(&ebml(&[0x63, 0xA2], huge >> 1, &[0; 16])));
|
||||
let codec_data = segment([info.clone(), ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)].concat());
|
||||
// The plain track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data.
|
||||
let track = pcm(&[]);
|
||||
let track = ebml(&[0xAE], track.len() as u64, &track);
|
||||
let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat());
|
||||
let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], track.len() as u64, &track), block].concat());
|
||||
let out_of_file = |r: Result<_, AudioError>| matches!(r, Err(AudioError::Malformed(m)) if m.contains("end of file"));
|
||||
for hint in [None, Some("mkv"), Some("wav")] {
|
||||
assert!(probe(&reported, hint).is_err(), "{hint:?}");
|
||||
assert!(decode(&reported, hint).is_err(), "{hint:?}");
|
||||
// Refused at the oversized read itself, not by anything before it.
|
||||
assert!(out_of_file(probe(&codec_data, hint).map(|_| ())), "{hint:?}: {:?}", probe(&codec_data, hint).err());
|
||||
assert!(out_of_file(decode(&codec_data, hint).map(|_| ())), "{hint:?}");
|
||||
// The file and its track open, so decoding reaches the block; the block reader stops at the
|
||||
// end of the file, a best-effort read of what is there.
|
||||
assert!(probe(&cluster, hint).is_ok(), "{hint:?}: {:?}", probe(&cluster, hint).err());
|
||||
assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_caf_still_reads() {
|
||||
let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
|
||||
@@ -302,6 +366,7 @@ fn packets_longer_than_any_codec_uses_are_refused() {
|
||||
}
|
||||
}
|
||||
|
||||
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
|
||||
#[test]
|
||||
fn flac_metadata_fields_beyond_their_block_are_refused() {
|
||||
let with_block = |kind: u8, body: &[u8]| {
|
||||
@@ -312,7 +377,8 @@ fn flac_metadata_fields_beyond_their_block_are_refused() {
|
||||
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
|
||||
assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300);
|
||||
let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat();
|
||||
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] {
|
||||
let vendor_overflow = [&0x7fff_fff0u32.to_le_bytes()[..], &0u32.to_le_bytes()].concat();
|
||||
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment), with_block(4, &vendor_overflow)] {
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! The length `probe` reports for AAC in MP4 is the length `decode` produces (issue #1113).
|
||||
|
||||
use soundcraft_audio_io::{decode, probe};
|
||||
|
||||
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
|
||||
}
|
||||
|
||||
/// A full atom (version 0, no flags) whose body is `fields` as big-endian u32s.
|
||||
fn full(kind: &[u8; 4], fields: &[u32]) -> Vec<u8> {
|
||||
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
|
||||
atom(kind, &body)
|
||||
}
|
||||
|
||||
/// An MPEG-4 descriptor with a one-byte length.
|
||||
fn descriptor(tag: u8, body: &[u8]) -> Vec<u8> {
|
||||
[&[tag, body.len() as u8][..], body].concat()
|
||||
}
|
||||
|
||||
/// A silent AAC-LC raw frame for one channel: an SCE with `max_sfb` 0, then ID_END.
|
||||
const SILENT_SCE: [u8; 4] = [0x00, 0xC8, 0x00, 0x07];
|
||||
|
||||
/// A mono 48 kHz AAC-LC MP4 without an edit list: `packets` frames, the last lasting `last_dur` in stts.
|
||||
fn aac_mp4(packets: u32, last_dur: u32) -> Vec<u8> {
|
||||
// AudioSpecificConfig: AAC-LC, 48 kHz, one channel.
|
||||
let dsi = descriptor(5, &[0x11, 0x88]);
|
||||
let dcd = descriptor(4, &[&[0x40, 0x15, 0, 0, 0][..], &[0; 8], &dsi].concat());
|
||||
let es = descriptor(3, &[&[0, 1, 0][..], &dcd, &descriptor(6, &[2])].concat());
|
||||
let esds = atom(b"esds", &[&[0; 4][..], &es].concat());
|
||||
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
|
||||
let entry = atom(b"mp4a", &[&base[..], &(48_000u32 << 16).to_be_bytes(), &esds].concat());
|
||||
let total = (packets - 1) * 1024 + last_dur;
|
||||
let moov = |offset: u32| {
|
||||
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat());
|
||||
let stbl = [
|
||||
stsd,
|
||||
full(b"stts", &[2, packets - 1, 1024, 1, last_dur]),
|
||||
full(b"stsc", &[1, 1, packets, 1]),
|
||||
full(b"stsz", &[SILENT_SCE.len() as u32, packets]),
|
||||
full(b"stco", &[1, offset]),
|
||||
];
|
||||
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
|
||||
let mdia = [full(b"mdhd", &[0, 0, 48_000, total, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
|
||||
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
|
||||
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, total, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
|
||||
};
|
||||
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
|
||||
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
|
||||
[ftyp, moov(offset), atom(b"mdat", &SILENT_SCE.repeat(packets as usize))].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aac_in_mp4_probe_length_matches_the_decoded_length() {
|
||||
// A last sample lasting until the track end (longer than a packet), and a control with a short one.
|
||||
for last_dur in [1145, 546] {
|
||||
let file = aac_mp4(10, last_dur);
|
||||
let (_, buf) = decode(&file, Some("m4a")).unwrap();
|
||||
assert_eq!(buf.frames(), 10 * 1024, "decoded frames, last stts duration {last_dur}");
|
||||
assert_eq!(probe(&file, Some("m4a")).unwrap().frames, buf.frames() as u64, "probed frames, last stts duration {last_dur}");
|
||||
}
|
||||
}
|
||||
@@ -316,3 +316,13 @@ fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
|
||||
let frames = u64::MAX / 4;
|
||||
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwf_truncation_keeps_utf8_boundaries() {
|
||||
let src = sine(48_000, 1, 10);
|
||||
let bwf = BwfInfo { originator: format!("{}é", "a".repeat(31)), description: format!("{}é", "d".repeat(255)), ..BwfInfo::default() };
|
||||
let bytes = encode(&src, &EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }).unwrap();
|
||||
let out = probe(&bytes, None).unwrap().bwf.unwrap();
|
||||
assert_eq!(out.originator, "a".repeat(31));
|
||||
assert_eq!(out.description, "d".repeat(255));
|
||||
}
|
||||
|
||||
@@ -317,4 +317,19 @@ mod tests {
|
||||
assert!(r.call("session.inspect", json!({})).is_ok());
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_returns_an_app_error_unchanged() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() };
|
||||
std::thread::spawn(move || {
|
||||
let Ok((mut sock, _)) = listener.accept() else { return };
|
||||
let mut line = String::new();
|
||||
let _ = BufReader::new(sock.try_clone().unwrap()).read_line(&mut line);
|
||||
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": false, "error": "transport.locate: nothing to reset"}));
|
||||
});
|
||||
let mut r = Remote::new(&addr);
|
||||
let err = r.call("engine.execute", json!({"command": "transport.locate"})).unwrap_err();
|
||||
assert_eq!(err, "transport.locate: nothing to reset");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,9 @@ impl<B: Backend> Server<B> {
|
||||
|
||||
/// Handle one JSON-RPC message; returns the reply line (None for notifications).
|
||||
pub fn handle(&mut self, msg: &Value) -> Option<Value> {
|
||||
if !msg.is_object() {
|
||||
return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request"}}));
|
||||
}
|
||||
let id = msg.get("id").cloned();
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
@@ -255,6 +258,9 @@ impl<B: Backend> Server<B> {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(Value::Array(batch)) if batch.is_empty() => {
|
||||
Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request: empty batch"}}))
|
||||
}
|
||||
Ok(Value::Array(batch)) => {
|
||||
let replies: Vec<Value> = batch.iter().filter_map(|m| self.handle(m)).collect();
|
||||
if replies.is_empty() { None } else { Some(Value::Array(replies)) }
|
||||
@@ -270,3 +276,39 @@ impl<B: Backend> Server<B> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Headless;
|
||||
use soundcraft_engine::Engine;
|
||||
|
||||
fn reply(line: &str) -> String {
|
||||
let mut server = Server::new(Headless::new(Engine::default()));
|
||||
let mut out = Vec::new();
|
||||
server.serve(line.as_bytes(), &mut out).unwrap();
|
||||
String::from_utf8(out).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_requests_get_an_invalid_request_error() {
|
||||
for input in ["[]", "42", "[1]", "\"x\""] {
|
||||
let text = reply(input);
|
||||
assert_eq!(text.lines().count(), 1, "input {input}: reply was {text:?}");
|
||||
let v: Value = serde_json::from_str(text.trim()).unwrap();
|
||||
let e = match &v {
|
||||
Value::Array(a) => a[0].clone(),
|
||||
other => other.clone(),
|
||||
};
|
||||
assert_eq!(e["error"]["code"], -32600, "input {input}");
|
||||
assert_eq!(e["id"], Value::Null, "input {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifications_still_get_no_reply() {
|
||||
assert_eq!(reply(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), "");
|
||||
let v: Value = serde_json::from_str(reply(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).trim()).unwrap();
|
||||
assert_eq!(v["id"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ harness = false
|
||||
clap-sys = "0.5"
|
||||
libloading = "0.9"
|
||||
|
||||
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
|
||||
# The isolated unsafe helper crate: this crate opts out of the workspace
|
||||
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
|
||||
# FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||
[lints.rust]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! plugins exactly like the built-in ones. Plugin ids are `clap:<CLAP plugin id>`; parameter ids
|
||||
//! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`].
|
||||
//!
|
||||
//! This is SoundCraft's single isolated `unsafe` crate (craftrules never-crash): `unsafe` is
|
||||
//! This is SoundCraft's single isolated `unsafe` crate: `unsafe` is
|
||||
//! denied crate-wide and allowed only in the private `ffi` module. The public API is safe, returns
|
||||
//! `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that itself crashes
|
||||
//! (segfault) takes the process down with it; we cannot sandbox native code.
|
||||
|
||||
@@ -395,3 +395,63 @@ impl LoudnessMeter {
|
||||
gain_to_db(self.true_peak)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn noise(len: usize, seed: u64, amp: f32) -> Vec<f32> {
|
||||
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
((x >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0) * amp
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// True peak as first written: the history shifted every sample, each phase summed on its own.
|
||||
fn true_peak_reference(coefs: &[[f32; TP_PHASES]; TP_TAPS], x: &[f32]) -> f32 {
|
||||
let mut h = [0.0f32; TP_TAPS];
|
||||
let mut tp = 0.0f32;
|
||||
for v in x.iter().map(|v| if v.is_finite() { *v } else { 0.0 }) {
|
||||
h.copy_within(0..TP_TAPS - 1, 1);
|
||||
h[0] = v;
|
||||
let mut peak = v.abs();
|
||||
for p in 0..TP_PHASES {
|
||||
let y: f32 = coefs.iter().zip(h.iter()).map(|(c, b)| c[p] * b).sum();
|
||||
peak = peak.max(y.abs());
|
||||
}
|
||||
if peak > tp {
|
||||
tp = peak;
|
||||
}
|
||||
}
|
||||
tp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn true_peak_matches_the_shifted_history() {
|
||||
let sr = 48_000.0;
|
||||
let len = 20_000;
|
||||
let mut l: Vec<f32> =
|
||||
(0..len).map(|i| 0.6 * (std::f32::consts::TAU * 0.25 * i as f32 + 0.7).sin()).zip(noise(len, 1, 0.3)).map(|(a, b)| a + b).collect();
|
||||
let r = noise(len, 2, 0.9);
|
||||
l[123] = f32::NAN;
|
||||
l[4_567] = f32::INFINITY;
|
||||
for block in [1, 7, 480, len] {
|
||||
let mut m = LoudnessMeter::new(sr, 2);
|
||||
let want = true_peak_reference(&m.tp_coefs, &l).max(true_peak_reference(&m.tp_coefs, &r));
|
||||
for at in (0..len).step_by(block) {
|
||||
let n = block.min(len - at);
|
||||
m.process(&[l[at..at + n].to_vec(), r[at..at + n].to_vec()], n);
|
||||
}
|
||||
assert_eq!(m.true_peak.to_bits(), want.to_bits(), "block {block}");
|
||||
m.reset();
|
||||
let quiet = noise(500, 3, 0.1);
|
||||
m.process(&[quiet.clone(), quiet.clone()], 500);
|
||||
assert_eq!(m.true_peak.to_bits(), true_peak_reference(&m.tp_coefs, &quiet).to_bits(), "after reset, block {block}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +127,11 @@ pub fn remove_dc(ch: &mut [Vec<f32>]) {
|
||||
if c.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mean = c.iter().filter(|v| v.is_finite()).map(|&v| f64::from(v)).sum::<f64>() / c.len() as f64;
|
||||
let m = mean as f32;
|
||||
let (sum, n) = c.iter().filter(|v| v.is_finite()).fold((0.0f64, 0usize), |(s, n), &v| (s + f64::from(v), n + 1));
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let m = (sum / n as f64) as f32;
|
||||
c.iter_mut().for_each(|s| *s -= m);
|
||||
}
|
||||
}
|
||||
@@ -889,4 +892,14 @@ mod tests {
|
||||
assert_eq!(ch[0][50], 1.0);
|
||||
fade(&mut ch, 1000, 1000, FadeShape::SCurve);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_dc_ignores_non_finite_samples_in_the_mean() {
|
||||
let mut ch = vec![vec![f32::NAN, 1.0, 1.0, 1.0]];
|
||||
remove_dc(&mut ch);
|
||||
assert!(ch[0][1..].iter().all(|x| x.abs() < 1e-6), "left over dc {:?}", ch[0]);
|
||||
let mut all_bad = vec![vec![f32::NAN, f32::INFINITY]];
|
||||
remove_dc(&mut all_bad);
|
||||
assert!(all_bad[0][0].is_nan() && all_bad[0][1].is_infinite());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,3 +671,44 @@ impl Plugin for Maximizer {
|
||||
self.gr_meter
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The window minimum as first written: the last `la` requests in a ring, scanned whole.
|
||||
#[test]
|
||||
fn window_min_matches_a_scan_of_the_last_requests() {
|
||||
let mut x = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = move || {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
x
|
||||
};
|
||||
for la in [1usize, 2, 3, 7, 64, 480] {
|
||||
let mut m = Maximizer::new();
|
||||
m.win = vec![(0, 1.0); la];
|
||||
let mut ring = vec![1.0f32; la];
|
||||
let mut at = 0;
|
||||
for i in 0..6_000 {
|
||||
if i == 3_000 {
|
||||
m.reset();
|
||||
ring.iter_mut().for_each(|v| *v = 1.0);
|
||||
at = 0;
|
||||
}
|
||||
let r = next();
|
||||
let req = match r % 16 {
|
||||
0 => f32::NAN,
|
||||
1 => 1.0,
|
||||
2 => 0.25,
|
||||
_ => (r >> 40) as f32 / (1u64 << 24) as f32 * 1.5,
|
||||
};
|
||||
ring[at] = req;
|
||||
at = (at + 1) % la;
|
||||
let want = ring.iter().copied().fold(1.0f32, f32::min);
|
||||
assert_eq!(m.window_min(req, la).to_bits(), want.to_bits(), "la {la}, sample {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,3 +310,17 @@ pub(crate) fn sort_events<T: Copy>(ev: &mut [T], key: impl Fn(&T) -> usize) {
|
||||
pub(crate) fn note_hz(note: f32) -> f32 {
|
||||
440.0 * 2f32.powf((note - 69.0) / 12.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn flush_matches_the_finite_and_magnitude_test() {
|
||||
let edge = 1.0e-25f32.to_bits();
|
||||
let special = [0, 0x8000_0000, 1, edge - 1, edge, edge + 1, 0x7f7f_ffff, 0x7f80_0000, 0xff80_0000, 0x7fc0_0000, 0xffc0_0001];
|
||||
for b in (0..=u32::MAX).step_by(997).chain(special).chain(special.map(|b| b | 0x8000_0000)) {
|
||||
let x = f32::from_bits(b);
|
||||
let want = if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 };
|
||||
assert_eq!(super::flush(x).to_bits(), want.to_bits(), "{b:#x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ impl Autosave {
|
||||
|
||||
/// Delete the folder: the session closed with nothing left to recover.
|
||||
fn discard(mut self) {
|
||||
drop(self.lock.take());
|
||||
self.lock = None;
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,7 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok());
|
||||
let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified());
|
||||
let recording = dir.join(RECORDING).is_file();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
drop(lock);
|
||||
match saved {
|
||||
Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {
|
||||
|
||||
@@ -89,7 +89,11 @@ fn decode_group_audio(
|
||||
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
|
||||
for (old, rel) in audio_entries {
|
||||
let full = dir.join(rel);
|
||||
if !full.starts_with(dir) || rel.contains("..") {
|
||||
if !full.starts_with(dir)
|
||||
|| Path::new(rel)
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_)))
|
||||
{
|
||||
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
}
|
||||
let key = crate::io::file_key(&full);
|
||||
@@ -233,6 +237,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_accepts_audio_names_with_consecutive_dots_but_not_parent_dirs() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-dots-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("drums.scgrp").to_string_lossy().to_string();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap();
|
||||
let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect();
|
||||
e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap();
|
||||
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
for (i, rel) in file["audio"].as_object_mut().unwrap().values_mut().enumerate() {
|
||||
let old = dir.join(rel.as_str().unwrap());
|
||||
let new = format!("Wait... what_{i}.wav");
|
||||
std::fs::rename(&old, dir.join(&new)).unwrap();
|
||||
*rel = json!(new);
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!("../escape.wav");
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "{r:?}");
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!(dir.join("Wait... what_0.wav").to_string_lossy());
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_clips_carry_no_auto_rename_scheme() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
|
||||
|
||||
@@ -28,6 +28,7 @@ mod plugin_state;
|
||||
mod query;
|
||||
pub(crate) mod sampler;
|
||||
mod setup_more;
|
||||
mod strip_silence;
|
||||
mod surround;
|
||||
mod tab;
|
||||
mod track;
|
||||
@@ -63,7 +64,7 @@ mod panic_probe {
|
||||
pub use file::bounce_job;
|
||||
|
||||
pub(crate) use edit::remember_selection;
|
||||
pub use edit::strip_silence_scan;
|
||||
pub use strip_silence::strip_silence_scan;
|
||||
|
||||
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
|
||||
/// `Err(reason)` = disabled.
|
||||
@@ -160,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(sampler::specs());
|
||||
v.extend(video::specs());
|
||||
v.extend(zoom_toggle::specs());
|
||||
v.extend(strip_silence::specs());
|
||||
#[cfg(test)]
|
||||
v.extend(panic_probe::specs());
|
||||
v
|
||||
@@ -322,11 +324,15 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Opt
|
||||
Err(bad(cmd, format!("`{key}` must be samples, {{seconds}}, or a time string")))
|
||||
}
|
||||
|
||||
/// Range from `start`/`end` params, else the edit selection.
|
||||
/// Range from `start`/`end`/`length` params. A missing `start` defaults to the edit selection start;
|
||||
/// with neither `end` nor `length` either, the whole edit selection is used.
|
||||
pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result<Range> {
|
||||
let st = position_param(e, cmd, p, "start")?;
|
||||
let mut st = if p.get("start").is_some_and(Value::is_null) { None } else { position_param(e, cmd, p, "start")? };
|
||||
let en = position_param(e, cmd, p, "end")?;
|
||||
let len = position_param(e, cmd, p, "length")?;
|
||||
if st.is_none() && (en.is_some() || len.is_some()) {
|
||||
st = Some(e.session().edit.selection.start);
|
||||
}
|
||||
match (st, en, len) {
|
||||
(Some(a), Some(b), _) => Ok(Range::new(a, b)),
|
||||
(Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))),
|
||||
|
||||
@@ -137,6 +137,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
nc.name = new_name;
|
||||
nc.content = ClipContent::Audio { source: src, offset: 0 };
|
||||
nc.length = i64::try_from(frames).unwrap_or(nc.length);
|
||||
nc.clamp_fades();
|
||||
// The render already has the clip's stretch applied; keeping it would stretch the new file again.
|
||||
nc.stretch = 1.0;
|
||||
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
|
||||
@@ -246,6 +247,21 @@ mod tests {
|
||||
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortening_process_clamps_fades_to_the_new_length() {
|
||||
let (mut e, id) = gained_clip();
|
||||
{
|
||||
let c = e.session_mut().find_clip_mut(id).unwrap();
|
||||
c.fade_in.len = 60;
|
||||
c.fade_out.len = 40;
|
||||
}
|
||||
e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 0.25}, "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
assert!(c.length < 64, "{}", c.length);
|
||||
assert!(c.fade_in.len <= c.length, "fade in {} > length {}", c.fade_in.len, c.length);
|
||||
assert_eq!(c.fade_out.len, 40.min(c.length));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processed_clip_does_not_keep_the_stretch_it_baked() {
|
||||
for stretch in [0.5, 2.0] {
|
||||
|
||||
@@ -228,6 +228,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
nc.content = soundcraft_model::ClipContent::Audio { source: src, offset: 0 };
|
||||
nc.length = out_len;
|
||||
nc.stretch = 1.0;
|
||||
nc.clamp_fades();
|
||||
super::more_util::replace_clip(s, t, *id, nc);
|
||||
rendered += 1;
|
||||
}
|
||||
@@ -246,7 +247,7 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if let Some((src, off, len)) = orig {
|
||||
c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
|
||||
c.length = len.max(1);
|
||||
c.fade_out.len = c.fade_out.len.min(c.length);
|
||||
c.clamp_fades();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -496,6 +497,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warping_and_unwarping_keep_fades_within_the_clip() {
|
||||
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
|
||||
if let Some(c) = e.session_mut().find_clip_mut(cid) {
|
||||
c.fade_in.len = 20_000;
|
||||
c.fade_out.len = 20_000;
|
||||
}
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap();
|
||||
let c = e.session().find_clip(cid).unwrap().1.clone();
|
||||
assert!(c.fade_in.len <= c.length && c.fade_out.len <= c.length, "{} {} {}", c.length, c.fade_in.len, c.fade_out.len);
|
||||
|
||||
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap();
|
||||
if let Some(c) = e.session_mut().find_clip_mut(cid) {
|
||||
c.fade_in.len = 40_000;
|
||||
}
|
||||
e.execute("clip.remove_warp", &json!({"clips": [cid.0]})).unwrap();
|
||||
let c = e.session().find_clip(cid).unwrap().1.clone();
|
||||
assert!(c.fade_in.len <= c.length, "{} {}", c.length, c.fade_in.len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -196,8 +196,14 @@ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn effects_set(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let params = p.get("params").and_then(Value::as_object).ok_or_else(|| bad("clip.effects_set", "`params` must be an object of numbers"))?;
|
||||
let vals: Vec<(String, f64)> =
|
||||
params.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))).collect();
|
||||
let vals: Vec<(String, f64)> = params
|
||||
.iter()
|
||||
.filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x)))
|
||||
.map(|(k, v)| {
|
||||
let v = if k == "gain" { v.clamp(f64::from(soundcraft_dsp::MIN_DB), f64::from(soundcraft_mix::CLIP_FX_MAX_GAIN_DB)) } else { v };
|
||||
(k, v)
|
||||
})
|
||||
.collect();
|
||||
let s = e.session_mut();
|
||||
for c in &ids {
|
||||
for (k, v) in &vals {
|
||||
@@ -316,6 +322,13 @@ mod tests {
|
||||
(Engine::new(s), t, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_set_caps_gain() {
|
||||
let (mut e, _t, ids) = engine_with(&[(0, 100, 0)]);
|
||||
e.execute("clip.effects_set", &json!({"clip": ids[0].0, "params": {"gain": 500.0}})).unwrap();
|
||||
assert_eq!(e.session().edit.values.get(&format!("clip_fx.{}.gain", ids[0].0)).copied(), Some(24.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloop_removes_copies() {
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100, 100, 0), (200, 100, 0), (300, 100, 50)]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Edit menu commands.
|
||||
use super::{
|
||||
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection,
|
||||
has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
|
||||
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, has_clipboard, has_range, has_selection, has_tracks,
|
||||
i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
|
||||
};
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
@@ -125,15 +125,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_range,
|
||||
strip_silence
|
||||
),
|
||||
cmd!(
|
||||
query "edit.strip_silence_preview",
|
||||
"Strip Silence Rectangles",
|
||||
[],
|
||||
None,
|
||||
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
|
||||
has_range,
|
||||
strip_silence_preview
|
||||
),
|
||||
cmd!(
|
||||
"edit.fades_create",
|
||||
"Create...",
|
||||
@@ -385,13 +376,7 @@ fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
if range.is_empty() && !ids.is_empty() {
|
||||
for t in &mut s.tracks {
|
||||
if let Some(pl) = t.playlist_mut() {
|
||||
let before = pl.clips.len();
|
||||
pl.clips.retain(|c| !ids.contains(&c.id));
|
||||
n += before - pl.clips.len();
|
||||
}
|
||||
}
|
||||
n = ops::remove_clips(s, &ids, sh);
|
||||
s.edit.selected_clips.clear();
|
||||
} else {
|
||||
for t in &tracks {
|
||||
@@ -821,38 +806,8 @@ fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"tracks": tracks.len()}))
|
||||
}
|
||||
|
||||
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
|
||||
type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
|
||||
|
||||
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
|
||||
/// command in errors.
|
||||
fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
|
||||
let tracks = tracks_required(e, cmd, p)?;
|
||||
let r = range_param(e, cmd, p)?;
|
||||
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
|
||||
let sr = e.session().sample_rate;
|
||||
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
|
||||
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
|
||||
}
|
||||
|
||||
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
|
||||
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
|
||||
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
|
||||
/// can be started.
|
||||
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
|
||||
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
|
||||
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
|
||||
}
|
||||
|
||||
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let found = strip_silence_scan(e, p)?.run()?;
|
||||
let tracks: Vec<Value> =
|
||||
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
|
||||
Ok(json!({"tracks": tracks}))
|
||||
}
|
||||
|
||||
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (tracks, r, thr, min_len, (pre, post)) = strip_settings(e, "edit.strip_silence", p)?;
|
||||
let (tracks, r, thr, min_len, (pre, post)) = super::strip_silence::strip_settings(e, "edit.strip_silence", p)?;
|
||||
let mode = match p.get("mode") {
|
||||
None => crate::io::StripMode::Strip,
|
||||
Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?,
|
||||
|
||||
@@ -115,11 +115,13 @@ fn tempo_constant(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let r = range_param(e, id, p)?;
|
||||
let s = e.session();
|
||||
let (t0, t1) = (s.tempo.samples_to_ticks(r.start, s.sample_rate), s.tempo.samples_to_ticks(r.end, s.sample_rate));
|
||||
if t1 <= t0 {
|
||||
return tempo(e, p);
|
||||
}
|
||||
let bpm = f64_or(p, "bpm", f64::NAN);
|
||||
let s = e.session_mut();
|
||||
if t1 <= t0 {
|
||||
// An empty range sets the tempo at its start, which may come from the selection.
|
||||
s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?;
|
||||
return Ok(json!({"tick": t0, "bpm": bpm}));
|
||||
}
|
||||
// Read before the edit: the end is a musical position, and the new tempo moves it in samples.
|
||||
let after = s.tempo.tempo_at_tick(t1);
|
||||
let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1);
|
||||
@@ -163,6 +165,13 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if m.start >= r.start {
|
||||
m.start += r.len();
|
||||
m.end += r.len();
|
||||
} else if m.end > r.start {
|
||||
m.end += r.len();
|
||||
}
|
||||
}
|
||||
for (at, _) in &mut s.key_signatures {
|
||||
if *at >= r.start {
|
||||
*at = at.saturating_add(r.len());
|
||||
}
|
||||
}
|
||||
Ok(json!({"inserted": r.len()}))
|
||||
@@ -190,6 +199,16 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if m.start >= r.end {
|
||||
m.start -= r.len();
|
||||
m.end -= r.len();
|
||||
} else if m.end >= r.end {
|
||||
m.end -= r.len();
|
||||
} else if m.end > r.start {
|
||||
m.end = r.start;
|
||||
}
|
||||
}
|
||||
s.key_signatures.retain(|(at, _)| !r.contains(*at));
|
||||
for (at, _) in &mut s.key_signatures {
|
||||
if *at >= r.end {
|
||||
*at -= r.len();
|
||||
}
|
||||
}
|
||||
Ok(json!({"removed": r.len()}))
|
||||
@@ -302,7 +321,10 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
(None, Some(a), Some(b)) => (a, b),
|
||||
(None, Some(p), None) | (None, None, Some(p)) => (p, p),
|
||||
_ if kind == MarkerKind::Selection => (sel.start, sel.end),
|
||||
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
|
||||
_ => {
|
||||
let point = if e.transport.playing { e.transport.position } else { sel.start };
|
||||
(point, point)
|
||||
}
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let n = s.markers.len() + 1;
|
||||
@@ -407,6 +429,46 @@ mod tests {
|
||||
use crate::demo::demo_engine;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tempo_constant_with_an_empty_range_sets_the_tempo_at_its_start() {
|
||||
use soundcraft_time::Range;
|
||||
let mut e = demo_engine();
|
||||
e.session_mut().edit.selection = Range::point(96_000);
|
||||
let r = e.execute("event.tempo_constant", &json!({"bpm": 90.0, "end": 96_000})).unwrap();
|
||||
let s = e.session();
|
||||
let tick = s.tempo.samples_to_ticks(96_000, s.sample_rate);
|
||||
assert_eq!(r["tick"], tick);
|
||||
assert_eq!(s.tempo.tempo_at_tick(tick), 90.0);
|
||||
}
|
||||
|
||||
/// `start` is optional for insert/cut time: it defaults to the selection start, then `end`/`length` apply.
|
||||
#[test]
|
||||
fn insert_and_cut_time_without_start_use_selection_start() {
|
||||
use soundcraft_time::Range;
|
||||
let mut e = demo_engine();
|
||||
let a = e.session().edit.selection.start;
|
||||
let run = |e: &mut crate::Engine, sel: Range, id: &str, params: serde_json::Value| {
|
||||
e.session_mut().edit.selection = sel;
|
||||
e.execute(id, ¶ms).unwrap()
|
||||
};
|
||||
// Point selection.
|
||||
assert_eq!(run(&mut e, Range::point(a), "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
|
||||
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
|
||||
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"length": 700}))["removed"], 700);
|
||||
// A non-empty selection must not override the given `end`/`length`.
|
||||
let sel = Range::new(a, a + 100);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"end": a + 2000}))["inserted"], 2000);
|
||||
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
|
||||
// A null `start` takes the same path.
|
||||
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"start": null, "end": a + 3000}))["removed"], 3000);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": null, "length": 900}))["inserted"], 900);
|
||||
// An explicit `start` still wins over the selection.
|
||||
let r = run(&mut e, sel, "event.cut_time", json!({"start": a + 1000, "end": a + 3000}));
|
||||
assert_eq!(r["removed"], 2000);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": a + 1000, "length": 500}))["inserted"], 500);
|
||||
}
|
||||
|
||||
/// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position.
|
||||
#[test]
|
||||
fn tempo_change_at_position_is_applied() {
|
||||
@@ -473,6 +535,19 @@ mod tests {
|
||||
assert_eq!(find(&c), Some((1000, 2000)));
|
||||
}
|
||||
|
||||
/// A plain marker added during playback is a point at the playhead, whatever the edit selection is.
|
||||
#[test]
|
||||
fn markers_add_while_playing_is_a_point_at_the_playhead() {
|
||||
let mut e = demo_engine();
|
||||
e.session_mut().edit.selection.start = 48_000;
|
||||
e.session_mut().edit.selection.end = 48_000;
|
||||
e.transport.playing = true;
|
||||
e.transport.position = 4800;
|
||||
let r = e.execute("markers.add", &json!({"name": "M"})).unwrap();
|
||||
let m = e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end));
|
||||
assert_eq!(m, Some((4800, 4800)));
|
||||
}
|
||||
|
||||
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
|
||||
#[test]
|
||||
fn edit_marker_sets_documented_color() {
|
||||
|
||||
@@ -274,7 +274,12 @@ fn tempo_curve(e: &mut Engine, p: &Value, curve: Curve, id: &str) -> Result<Valu
|
||||
|
||||
fn tempo_stretch(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "event.tempo_stretch";
|
||||
let (r, t0, t1) = selection_ticks(e, p, id)?;
|
||||
// Here `length` is the new length of the selection, not part of the range.
|
||||
let mut range_params = p.clone();
|
||||
if let Some(m) = range_params.as_object_mut() {
|
||||
m.remove("length");
|
||||
}
|
||||
let (r, t0, t1) = selection_ticks(e, &range_params, id)?;
|
||||
let factor = match position_param(e, id, p, "length")? {
|
||||
Some(l) if l > 0 => l as f64 / r.len() as f64,
|
||||
_ => f64_or(p, "factor", f64::NAN),
|
||||
@@ -901,6 +906,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// For `event.tempo_stretch`, `length` is the new length of the selection, so the selection stays the range.
|
||||
#[test]
|
||||
fn tempo_stretch_length_keeps_selection_range() {
|
||||
let mut e = Engine::default();
|
||||
e.session_mut().edit.selection = Range::new(0, 96_000);
|
||||
e.execute("event.tempo_stretch", &json!({"length": 192_000})).unwrap();
|
||||
assert_eq!(e.session().tempo.tempo_at_tick(0), 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_curves() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -335,7 +335,11 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
|
||||
return Err(bad(id, "no MIDI or instrument tracks to notate"));
|
||||
}
|
||||
let setup = crate::score::ScoreSetup::from_session(e.session());
|
||||
let (text, ext) = if svg { (crate::score::svg(&parts, &setup), "svg") } else { (crate::score::musicxml(&parts, &setup), "musicxml") };
|
||||
let (text, ext) = if svg {
|
||||
(crate::score::svg(&parts, &setup).map_err(|m| bad(id, m))?, "svg")
|
||||
} else {
|
||||
(crate::score::musicxml(&parts, &setup).map_err(|m| bad(id, m))?, "musicxml")
|
||||
};
|
||||
let path = match str_param(p, "path") {
|
||||
Some(p) => p.to_string(),
|
||||
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
|
||||
|
||||
@@ -124,6 +124,12 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let dt = i64_or(p, "ticks", 0);
|
||||
let ds = i64_or(p, "semitones", 0);
|
||||
let seq = seq_mut(e, id, "midi.notes_move")?;
|
||||
// Clamp the shared delta so the earliest selected note stops at tick 0 and the selection keeps its spacing.
|
||||
let earliest = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| n.start).min();
|
||||
let dt = match earliest {
|
||||
Some(m) => dt.max(0i64.saturating_sub(m).min(0)),
|
||||
None => dt,
|
||||
};
|
||||
for (i, n) in seq.notes.iter_mut().enumerate() {
|
||||
if idx.contains(&i) {
|
||||
n.start = n.start.saturating_add(dt).max(0);
|
||||
@@ -168,6 +174,36 @@ mod tests {
|
||||
assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_move_left_past_zero_keeps_spacing() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||
let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
for s in [100, 200, 300] {
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();
|
||||
}
|
||||
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": -150})).unwrap();
|
||||
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();
|
||||
assert_eq!(starts, [0, 100, 300]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_move_without_left_overflow_does_not_shift_selection() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||
let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap();
|
||||
for s in [0, 100] {
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();
|
||||
}
|
||||
seq_mut(&mut e, ClipId(clip), "test").unwrap().notes[0].start = -50;
|
||||
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": 0})).unwrap();
|
||||
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();
|
||||
assert_eq!(starts, [0, 100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_move_huge_params_do_not_overflow() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -155,6 +155,14 @@ pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples)
|
||||
if !(0.1..=10.0).contains(&ratio) {
|
||||
return Err(bad(cmd, format!("stretch ratio {ratio:.3} is outside 0.1..10")));
|
||||
}
|
||||
// The source span read in can be 10× the output, and both are held for every channel: bound
|
||||
// each like any other offline render (a session's clip length and stretch are not trusted).
|
||||
let channels = audio.buffer.channels.len().max(1);
|
||||
let limit = crate::io::render_limit(channels);
|
||||
if src_len > limit as f64 || usize::try_from(out_len).map_or(true, |n| n > limit) {
|
||||
let secs = limit as f64 / s.sample_rate.as_f64();
|
||||
return Err(bad(cmd, format!("the clip is too long to stretch: one render holds at most {secs:.0} s of {channels}-channel audio")));
|
||||
}
|
||||
let a = usize::try_from(offset.max(0)).unwrap_or(0);
|
||||
let n = src_len as usize;
|
||||
let ch: Vec<Vec<f32>> = audio
|
||||
@@ -213,4 +221,38 @@ mod tests {
|
||||
s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap();
|
||||
assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples);
|
||||
}
|
||||
|
||||
/// A stereo session with a one-frame source and one clip on it.
|
||||
fn stretched_clip(length: Samples, stretch: f64) -> (Session, Clip) {
|
||||
use soundcraft_model::{ChannelFormat, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Long"));
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5]; 2] };
|
||||
let src = crate::io::add_source(&mut s, "One", buf, None, FileFormat::Wav);
|
||||
let cid = s.new_clip_id();
|
||||
let mut clip = Clip::audio(cid, "Clip", src, 0, 0, length);
|
||||
clip.stretch = stretch;
|
||||
crate::edit::place_clip(&mut s, t, clip.clone());
|
||||
(s, clip)
|
||||
}
|
||||
|
||||
/// #895: a session's clip length and stretch made the source span read in 10× the one-hour
|
||||
/// output limit, for every channel (about 14 GB here), and the allocation aborted the app.
|
||||
#[test]
|
||||
fn a_stretch_over_the_render_limit_is_refused_before_allocating() {
|
||||
let hour = Session::default().sample_rate.samples(3600.0);
|
||||
let (mut s, clip) = stretched_clip(hour, 0.1);
|
||||
let err = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap_err();
|
||||
assert!(err.to_string().contains("too long to stretch"), "{err}");
|
||||
|
||||
let mut e = crate::Engine::new(s);
|
||||
assert!(e.execute("clip.remove_pitch_shift", &serde_json::json!({"clips": [clip.id.0]})).is_err());
|
||||
let (_, kept) = e.session().find_clip(clip.id).unwrap();
|
||||
assert_eq!((kept.stretch, kept.length), (0.1, hour), "the clip is left as it was");
|
||||
|
||||
// An ordinary stretch still renders, at the length asked for.
|
||||
let (mut s, clip) = stretched_clip(1_000, 0.5);
|
||||
let src = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap().unwrap();
|
||||
assert!(s.pool.get(src).unwrap().buffer.channels.iter().all(|c| c.len() == 1_000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,13 +42,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
prepare_dpe
|
||||
),
|
||||
cmd!(noundo "setup.hardware", "Hardware...", ["Setup"], None, "{device?, buffer_size?: 16..8192} — audio interface settings (sample rate is the session's)", always, |e, p| engine_settings(e, p)),
|
||||
cmd!(noundo "setup.playback_engine", "Playback Engine...", ["Setup"], None, "{device?, buffer_size?: 16..8192, delay_compensation?: bool}", always, |e, p| {
|
||||
if let Some(v) = p.get("delay_compensation").and_then(Value::as_bool) {
|
||||
e.session_mut().edit.delay_compensation = v;
|
||||
}
|
||||
engine_settings(e, p)
|
||||
}),
|
||||
cmd!(
|
||||
noundo "setup.hardware",
|
||||
"Hardware...",
|
||||
["Setup"],
|
||||
None,
|
||||
"{device?: output name, buffer_size?: 16..=8192 or null} — output device and buffer. Applied only in the desktop app (Playback Engine window / ui.audio_device), which returns the open device. Headless stores the request and returns applied: false; buffer_size is null when unset. Any integer in range, not rounded to the window's power-of-two sizes. Sample rate is the session's",
|
||||
always,
|
||||
|e, p| engine_settings(e, p, "setup.hardware")
|
||||
),
|
||||
cmd!(
|
||||
noundo "setup.playback_engine",
|
||||
"Playback Engine...",
|
||||
["Setup"],
|
||||
None,
|
||||
"{device?: output name, buffer_size?: 16..=8192 or null, delay_compensation?: bool} — device and buffer as setup.hardware (applied only in the desktop app, or via ui.audio_device); delay_compensation is applied here too. Headless returns applied: false and does not invent a buffer",
|
||||
always,
|
||||
|e, p| engine_settings(e, p, "setup.playback_engine")
|
||||
),
|
||||
cmd!(noundo "setup.disk_allocation", "Disk Allocation...", ["Setup"], None, "{path?, track?} — the record folder for the session, or for one track", always, disk_allocation),
|
||||
cmd!(noundo "setup.peripherals", "Peripherals...", ["Setup"], None, "{settings?: {name: number|bool|string}} — control surface / sync device settings", always, peripherals),
|
||||
cmd!(
|
||||
@@ -156,18 +167,69 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"prepared": n}))
|
||||
}
|
||||
|
||||
fn engine_settings(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
/// A `buffer_size` argument: absent, cleared (`null`), or an integer in 16..=8192.
|
||||
enum BufferParam {
|
||||
Skip,
|
||||
Clear,
|
||||
Set(u64),
|
||||
}
|
||||
|
||||
/// Same range as `ui.audio_device`. Out of range and non-numbers error; there is no clamp and no
|
||||
/// power-of-two rounding (the Playback Engine window lists those sizes, the command keeps the number).
|
||||
fn buffer_param(id: &str, p: &Value) -> Result<BufferParam> {
|
||||
match p.get("buffer_size") {
|
||||
None => Ok(BufferParam::Skip),
|
||||
Some(Value::Null) => Ok(BufferParam::Clear),
|
||||
Some(v) => {
|
||||
let n = v.as_u64().filter(|n| (16..=8192).contains(n)).ok_or_else(|| bad(id, "`buffer_size` must be a number in 16..=8192 or null"))?;
|
||||
Ok(BufferParam::Set(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `device` as `ui.audio_device`'s `output`: a name, or `null`/empty to clear. Absent leaves it.
|
||||
fn device_param<'a>(id: &str, p: &'a Value) -> Result<Option<Option<&'a str>>> {
|
||||
match p.get("device") {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::String(name)) => Ok(Some((!name.is_empty()).then_some(name.as_str()))),
|
||||
Some(_) => Err(bad(id, "`device` must be a string or null")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Headless `setup.hardware` / `setup.playback_engine`. Playback never reads `engine.device` or
|
||||
/// `engine.buffer_size` (only this command does); they are kept so a later call can report the
|
||||
/// request. The result says `applied: false` and leaves `buffer_size` null until one was stored.
|
||||
fn engine_settings(e: &mut Engine, p: &Value, id: &str) -> Result<Value> {
|
||||
let buffer = buffer_param(id, p)?;
|
||||
let device = device_param(id, p)?;
|
||||
if id == "setup.playback_engine"
|
||||
&& let Some(on) = p.get("delay_compensation").and_then(Value::as_bool)
|
||||
{
|
||||
e.session_mut().edit.delay_compensation = on;
|
||||
}
|
||||
let s = e.session_mut();
|
||||
if let Some(b) = p.get("buffer_size").and_then(Value::as_u64) {
|
||||
let b = b.clamp(16, 8192).next_power_of_two().min(8192);
|
||||
s.edit.values.insert("engine.buffer_size".into(), b as f64);
|
||||
match buffer {
|
||||
BufferParam::Skip => {}
|
||||
BufferParam::Clear => {
|
||||
s.edit.values.remove("engine.buffer_size");
|
||||
}
|
||||
BufferParam::Set(n) => {
|
||||
s.edit.values.insert("engine.buffer_size".into(), n as f64);
|
||||
}
|
||||
}
|
||||
if let Some(d) = str_param(p, "device") {
|
||||
set_text(&mut s.edit, "engine.device", Some(d));
|
||||
if let Some(name) = device {
|
||||
set_text(&mut s.edit, "engine.device", name);
|
||||
}
|
||||
// Exact integers only: a hand-edited fraction is not reported as a buffer size.
|
||||
let buffer_size = s.edit.values.get("engine.buffer_size").copied().and_then(|v| {
|
||||
let n = v as u64;
|
||||
(v.is_finite() && (n as f64) == v).then_some(n)
|
||||
});
|
||||
Ok(json!({
|
||||
"applied": false,
|
||||
"device": text(&s.edit, "engine.device"),
|
||||
"buffer_size": s.edit.value("engine.buffer_size", 1024.0),
|
||||
"buffer_size": buffer_size,
|
||||
"sample_rate": s.sample_rate.hz(),
|
||||
"bit_depth": s.bit_depth,
|
||||
"delay_compensation": s.edit.delay_compensation,
|
||||
@@ -307,7 +369,7 @@ fn current_timecode(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let at = s.edit.selection.start.max(0);
|
||||
let abs = tc.to_samples(s.frame_rate, s.sample_rate);
|
||||
s.timecode_start = abs - at;
|
||||
s.timecode_start = abs.saturating_sub(at).clamp(-soundcraft_time::MAX_POSITION, soundcraft_time::MAX_POSITION);
|
||||
Ok(json!({"timecode_start": s.timecode_start, "at": at}))
|
||||
}
|
||||
|
||||
@@ -567,6 +629,20 @@ mod tests {
|
||||
assert_eq!(shown, "01:00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_timecode_out_of_range_is_clamped() {
|
||||
use soundcraft_time::MAX_POSITION;
|
||||
for tc in ["-9223372036854775807:00:00:00", "9999999:00:00:00"] {
|
||||
let mut e = Engine::default();
|
||||
e.execute("edit.select", &json!({"start": 48_000})).unwrap();
|
||||
e.execute("setup.current_timecode", &json!({"timecode": tc})).unwrap();
|
||||
let start = e.session().timecode_start;
|
||||
assert!((-MAX_POSITION..=MAX_POSITION).contains(&start), "{tc}: start {start}");
|
||||
let reloaded = Session::from_json(&e.session().to_json().unwrap()).unwrap();
|
||||
assert_eq!(reloaded.timecode_start, start, "{tc}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_countoff_settings() {
|
||||
let mut e = Engine::default();
|
||||
@@ -587,7 +663,8 @@ mod tests {
|
||||
fn engine_and_midi_settings() {
|
||||
let mut e = Engine::default();
|
||||
let r = e.execute("setup.playback_engine", &json!({"buffer_size": 300, "device": "Interface"})).unwrap();
|
||||
assert_eq!(r["buffer_size"], 512.0);
|
||||
assert_eq!(r["applied"], false);
|
||||
assert_eq!(r["buffer_size"], 300);
|
||||
assert_eq!(r["device"], "Interface");
|
||||
let r = e.execute("setup.midi_input_filter", &json!({"filter": ["sysex", "bogus"]})).unwrap();
|
||||
assert_eq!(r["filtered"], json!(["sysex"]));
|
||||
@@ -597,6 +674,45 @@ mod tests {
|
||||
assert_eq!(r["samples"], 3200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_engine_does_not_pretend_the_device_changed() {
|
||||
let mut e = Engine::default();
|
||||
let r = e.execute("setup.playback_engine", &json!({})).unwrap();
|
||||
assert_eq!(r["applied"], false);
|
||||
assert!(r["buffer_size"].is_null(), "no invented default buffer: {r}");
|
||||
assert!(r["device"].is_null());
|
||||
assert_eq!(r["delay_compensation"], true, "the session default, not a device claim");
|
||||
|
||||
let r = e.execute("setup.hardware", &json!({"buffer_size": 300, "device": "Interface"})).unwrap();
|
||||
assert_eq!(r["applied"], false);
|
||||
assert_eq!(r["buffer_size"], 300, "kept, not rounded to a power of two");
|
||||
assert_eq!(r["device"], "Interface");
|
||||
assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0);
|
||||
|
||||
let r = e.execute("setup.playback_engine", &json!({"delay_compensation": false})).unwrap();
|
||||
assert_eq!(r["applied"], false);
|
||||
assert_eq!(r["delay_compensation"], false);
|
||||
assert_eq!(r["buffer_size"], 300, "delay compensation does not clear a stored buffer");
|
||||
assert!(!e.session().edit.delay_compensation);
|
||||
|
||||
let err = e.execute("setup.playback_engine", &json!({"buffer_size": 8, "delay_compensation": true, "device": "Other"})).unwrap_err();
|
||||
assert!(err.to_string().contains("16..=8192"), "{err}");
|
||||
assert!(!e.session().edit.delay_compensation, "an invalid buffer changes nothing");
|
||||
assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0);
|
||||
assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface"));
|
||||
assert!(e.execute("setup.hardware", &json!({"buffer_size": "big"})).is_err());
|
||||
|
||||
let err = e.execute("setup.playback_engine", &json!({"device": 42, "delay_compensation": true})).unwrap_err();
|
||||
assert!(err.to_string().contains("`device` must be a string or null"), "{err}");
|
||||
assert!(!e.session().edit.delay_compensation, "an invalid device changes nothing");
|
||||
assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface"));
|
||||
|
||||
let r = e.execute("setup.hardware", &json!({"buffer_size": null, "device": ""})).unwrap();
|
||||
assert_eq!(r["applied"], false);
|
||||
assert!(r["buffer_size"].is_null());
|
||||
assert!(r["device"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_session_data_brings_tracks_and_audio() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-import-{}", std::process::id()));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Strip Silence Rectangles: the ranges Strip Silence keeps, which the Strip Silence window draws.
|
||||
use super::{CommandSpec, f32_or, f64_or, has_range, range_param, tracks_required};
|
||||
use soundcraft_model::TrackId;
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
query "edit.strip_silence_preview",
|
||||
"Strip Silence Rectangles",
|
||||
[],
|
||||
None,
|
||||
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
|
||||
has_range,
|
||||
strip_silence_preview
|
||||
)]
|
||||
}
|
||||
|
||||
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
|
||||
pub(super) type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
|
||||
|
||||
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
|
||||
/// command in errors.
|
||||
pub(super) fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
|
||||
let tracks = tracks_required(e, cmd, p)?;
|
||||
let r = range_param(e, cmd, p)?;
|
||||
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
|
||||
let sr = e.session().sample_rate;
|
||||
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
|
||||
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
|
||||
}
|
||||
|
||||
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
|
||||
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
|
||||
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
|
||||
/// can be started.
|
||||
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
|
||||
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
|
||||
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
|
||||
}
|
||||
|
||||
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let found = strip_silence_scan(e, p)?.run()?;
|
||||
let tracks: Vec<Value> =
|
||||
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
|
||||
Ok(json!({"tracks": tracks}))
|
||||
}
|
||||
@@ -590,6 +590,20 @@ fn owned_folder_bus(s: &Session, folder: TrackId) -> Option<soundcraft_model::Bu
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a bus is still routed through by a track that survives deleting
|
||||
/// `deleted`, ignoring the output slots of `folder`'s own members, which the
|
||||
/// delete reroutes to the parent folder's bus. A folder owns its input bus
|
||||
/// only when this is false: `track.input` or a session file can point a
|
||||
/// folder at a bus that other tracks already use (#914).
|
||||
fn bus_used_beyond_folder(s: &Session, bus: soundcraft_model::BusId, deleted: &[TrackId], folder: TrackId) -> bool {
|
||||
let route = Route::Bus(bus);
|
||||
s.tracks.iter().filter(|track| !deleted.contains(&track.id)).any(|track| {
|
||||
track.mixer.input == route
|
||||
|| track.mixer.sends.iter().flatten().any(|send| send.target == route)
|
||||
|| (track.mixer.output == route && !in_folder(s, track.id, folder))
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep only the selected roots so selecting a folder and one of its children preserves the subtree.
|
||||
fn root_tracks(s: &Session, tracks: Vec<TrackId>) -> Vec<TrackId> {
|
||||
tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect()
|
||||
@@ -708,7 +722,11 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
.filter_map(|folder| {
|
||||
let bus = owned_folder_bus(s, *folder)?;
|
||||
let parent = surviving_parent(s, &tracks, *folder);
|
||||
Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main)))
|
||||
let replacement = parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main);
|
||||
// A bus that surviving tracks route through is shared, not the
|
||||
// folder's own: keep it, and every route to it, when the folder
|
||||
// goes (#914).
|
||||
(!bus_used_beyond_folder(s, bus, &tracks, *folder)).then_some((bus, replacement))
|
||||
})
|
||||
.collect();
|
||||
// Promote children of deleted folders and repair only routes that used the deleted
|
||||
@@ -1129,6 +1147,26 @@ mod tests {
|
||||
assert!(e.session().bus(outer_bus).is_some(), "the surviving parent folder's bus must remain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_folder_keeps_a_bus_other_tracks_still_use() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
// #914: the demo session's Verb bus is shared: an aux takes it as its input
|
||||
// and several tracks send to it. Point a new folder's input at it, the way
|
||||
// `track.input` or a session file can, then delete the folder.
|
||||
let verb = e.session().bus_by_name("Verb").unwrap().id;
|
||||
let folder = e.execute("track.new", &json!({"kind": "folder", "name": "Shared"})).unwrap();
|
||||
let folder = TrackId(folder["tracks"][0].as_u64().unwrap());
|
||||
e.execute("track.input", &json!({"tracks": [folder.0], "input": "Verb"})).unwrap();
|
||||
assert_eq!(e.session().track(folder).unwrap().mixer.input, Route::Bus(verb));
|
||||
e.execute("track.delete", &json!({"tracks": [folder.0]})).unwrap();
|
||||
assert!(e.session().track(folder).is_none(), "the folder itself is deleted");
|
||||
assert!(e.session().bus(verb).is_some(), "a bus other tracks route through must survive");
|
||||
let aux = e.session().track_by_name("Verb").unwrap();
|
||||
assert_eq!(aux.mixer.input, Route::Bus(verb), "the aux input must keep the bus");
|
||||
let snare = e.session().track_by_name("Snare").unwrap();
|
||||
assert!(snare.mixer.sends.iter().flatten().any(|send| send.target == Route::Bus(verb)), "sends to the shared bus must survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heights_take_points_and_step_through_the_presets() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -9,9 +9,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "transport.play", "Play", [], Some("Space"), "{from?: position}", always, |e, p| {
|
||||
if let Some(at) = position_param(e, "transport.play", p, "from")? {
|
||||
e.transport_requests.push(TransportRequest::Locate(at.max(0)));
|
||||
e.transport_requests.push(TransportRequest::PlayFrom(at.max(0)));
|
||||
} else {
|
||||
e.transport_requests.push(TransportRequest::Play);
|
||||
}
|
||||
e.transport_requests.push(TransportRequest::Play);
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(noundo "transport.stop", "Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Stop); Ok(json!({})) }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before.
|
||||
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. Cancel Zoom Toggle turns it off
|
||||
//! and keeps the current view.
|
||||
use super::{CommandSpec, always, f64_or};
|
||||
use soundcraft_model::{TrackHeight, ZoomToggle};
|
||||
use soundcraft_time::MAX_POSITION;
|
||||
@@ -7,7 +8,16 @@ use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)]
|
||||
vec![
|
||||
cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle),
|
||||
cmd!(noundo "view.zoom_toggle_cancel", "Cancel Zoom Toggle", [], Some("Alt+Shift+E"), "{}", always, cancel),
|
||||
]
|
||||
}
|
||||
|
||||
/// Forget the view Zoom Toggle would go back to, leaving the zoom, scroll and track heights as they are.
|
||||
fn cancel(e: &mut Engine, _: &Value) -> Result<Value> {
|
||||
let was_on = e.session_mut().edit.zoom_toggle.take().is_some();
|
||||
Ok(json!({"on": false, "canceled": was_on}))
|
||||
}
|
||||
|
||||
fn toggle(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
@@ -73,6 +83,24 @@ mod tests {
|
||||
assert_eq!(view(&e), (500.0, 1000, vec![Medium, Fit(299), Medium]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_keeps_the_zoomed_view_and_the_next_toggle_zooms_in_again() {
|
||||
let mut e = engine(json!({"tracks": ["Audio 1", "Audio 3"], "start": 48_000, "end": 96_000}));
|
||||
e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap();
|
||||
let zoomed = view(&e);
|
||||
assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": true}));
|
||||
assert_eq!(e.session().edit.zoom_toggle, None);
|
||||
assert_eq!(view(&e), zoomed, "cancel keeps the view Zoom Toggle zoomed in to");
|
||||
assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": false}));
|
||||
assert_eq!(view(&e), zoomed);
|
||||
e.execute("view.zoom_set", &json!({"samples_per_px": 200.0})).unwrap();
|
||||
assert_eq!(e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(), json!({"on": true}));
|
||||
e.execute("view.zoom_toggle", &json!({})).unwrap();
|
||||
assert_eq!(view(&e), (200.0, 48_000, vec![Fit(249), Medium, Fit(249)]), "the toggle goes back to the view it saw after the cancel");
|
||||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "view.zoom_toggle_cancel").unwrap();
|
||||
assert_eq!(spec.shortcut, Some("Alt+Shift+E"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_and_stored_views_stay_in_range() {
|
||||
let mut e = engine(json!({"tracks": ["Audio 1"], "start": 0, "end": MAX_POSITION}));
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Borrowed document equality for the saved-state indicator.
|
||||
|
||||
use soundcraft_model::{EditState, Session};
|
||||
|
||||
/// Equal apart from the selection, playhead and zoom, without copying document data.
|
||||
pub(super) fn same_document(saved: &Session, doc: &Session) -> bool {
|
||||
document_values(saved, &saved.edit) == document_values(doc, &saved.edit)
|
||||
}
|
||||
|
||||
fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl PartialEq + 'a {
|
||||
// No `..`: adding a document field must also decide how it affects saved-state equality.
|
||||
let Session {
|
||||
format_version,
|
||||
name,
|
||||
sample_rate,
|
||||
bit_depth,
|
||||
frame_rate,
|
||||
timecode_start,
|
||||
tempo,
|
||||
key_signatures,
|
||||
tracks,
|
||||
sources,
|
||||
videos,
|
||||
busses,
|
||||
outputs,
|
||||
markers,
|
||||
groups,
|
||||
auto_names,
|
||||
edit,
|
||||
next_id,
|
||||
comments,
|
||||
midi_originals,
|
||||
pool,
|
||||
folder,
|
||||
} = s;
|
||||
// Tuples implement PartialEq up to twelve elements. These contain only borrowed fields.
|
||||
(
|
||||
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, tracks, sources, videos, busses),
|
||||
(outputs, markers, groups, auto_names, edit_values(edit, navigation), next_id, comments, midi_originals, pool, folder),
|
||||
)
|
||||
}
|
||||
|
||||
fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl PartialEq + 'a {
|
||||
let EditState {
|
||||
tool,
|
||||
edit_mode,
|
||||
selection: _,
|
||||
selected_tracks: _,
|
||||
selected_clips: _,
|
||||
selected_sources,
|
||||
playhead: _,
|
||||
grid,
|
||||
nudge,
|
||||
grid_lines,
|
||||
main_counter,
|
||||
sub_counter,
|
||||
zoom: _,
|
||||
zoom_toggle,
|
||||
rulers,
|
||||
link_timeline_edit,
|
||||
link_track_edit,
|
||||
insertion_follows_playback,
|
||||
loop_playback,
|
||||
pre_post_roll,
|
||||
pre_roll,
|
||||
post_roll,
|
||||
tab_to_transient,
|
||||
automation_follows_edit,
|
||||
markers_follow_edit,
|
||||
layered_editing,
|
||||
mirrored_midi,
|
||||
click,
|
||||
countoff,
|
||||
countoff_bars,
|
||||
midi_merge,
|
||||
record_mode,
|
||||
loop_record,
|
||||
quickpunch,
|
||||
scrolling,
|
||||
solo_mode,
|
||||
pre_fader_metering,
|
||||
delay_compensation,
|
||||
keyboard_focus,
|
||||
timeline_selection: _,
|
||||
flags,
|
||||
values,
|
||||
} = edit;
|
||||
// Borrow the saved navigation on both sides instead of overwriting a document clone.
|
||||
// Keeping these values in the comparison also preserves PartialEq for non-finite zooms.
|
||||
(
|
||||
(
|
||||
tool,
|
||||
edit_mode,
|
||||
&navigation.selection,
|
||||
&navigation.selected_tracks,
|
||||
&navigation.selected_clips,
|
||||
selected_sources,
|
||||
&navigation.playhead,
|
||||
grid,
|
||||
nudge,
|
||||
grid_lines,
|
||||
main_counter,
|
||||
sub_counter,
|
||||
),
|
||||
(
|
||||
&navigation.zoom,
|
||||
zoom_toggle,
|
||||
rulers,
|
||||
link_timeline_edit,
|
||||
link_track_edit,
|
||||
insertion_follows_playback,
|
||||
loop_playback,
|
||||
pre_post_roll,
|
||||
pre_roll,
|
||||
post_roll,
|
||||
tab_to_transient,
|
||||
automation_follows_edit,
|
||||
),
|
||||
(
|
||||
markers_follow_edit,
|
||||
layered_editing,
|
||||
mirrored_midi,
|
||||
click,
|
||||
countoff,
|
||||
countoff_bars,
|
||||
midi_merge,
|
||||
record_mode,
|
||||
loop_record,
|
||||
quickpunch,
|
||||
scrolling,
|
||||
solo_mode,
|
||||
),
|
||||
(pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_model::{ChannelFormat, Clip, ClipId, SourceId, TrackId, TrackKind, ZoomToggle};
|
||||
use soundcraft_time::{Range, TimeFormat};
|
||||
use std::sync::Arc;
|
||||
|
||||
// The previous comparison is an oracle for preserving the saved-state policy.
|
||||
fn previous_same_document(saved: &Session, doc: &Session) -> bool {
|
||||
let mut doc = doc.clone();
|
||||
let (from, to) = (&saved.edit, &mut doc.edit);
|
||||
to.selection = from.selection;
|
||||
to.timeline_selection = from.timeline_selection;
|
||||
to.selected_tracks.clone_from(&from.selected_tracks);
|
||||
to.selected_clips.clone_from(&from.selected_clips);
|
||||
to.playhead = from.playhead;
|
||||
to.zoom = from.zoom;
|
||||
*saved == doc
|
||||
}
|
||||
|
||||
fn check(saved: &Session, doc: &Session, expected: bool, field: &str) {
|
||||
assert_eq!(same_document(saved, doc), expected, "{field}");
|
||||
assert_eq!(same_document(doc, saved), expected, "{field}, reversed");
|
||||
assert_eq!(same_document(saved, doc), previous_same_document(saved, doc), "{field}, previous policy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_fields_are_ignored() {
|
||||
let saved = Session::default();
|
||||
let mut doc = saved.clone();
|
||||
doc.edit.selection = Range::new(100, 200);
|
||||
doc.edit.timeline_selection = Range::new(300, 400);
|
||||
doc.edit.selected_tracks = vec![TrackId(50)];
|
||||
doc.edit.selected_clips = vec![ClipId(60)];
|
||||
doc.edit.playhead = 500;
|
||||
doc.edit.zoom.samples_per_px = 64.0;
|
||||
doc.edit.zoom.scroll = 600;
|
||||
doc.edit.zoom.waveform_zoom = 2.0;
|
||||
doc.edit.zoom.presets = [1.0; 5];
|
||||
check(&saved, &doc, true, "navigation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_edit_settings_still_change_document_equality() {
|
||||
let saved = Session::default();
|
||||
let edits: &[(&str, fn(&mut EditState))] = &[
|
||||
("tool", |e| e.tool = soundcraft_model::Tool::Grabber),
|
||||
("edit_mode", |e| e.edit_mode = soundcraft_model::EditMode::Grid),
|
||||
("selected_sources", |e| e.selected_sources.push(SourceId(1))),
|
||||
("grid", |e| e.grid = soundcraft_time::GridValue::Seconds(2.0)),
|
||||
("nudge", |e| e.nudge = soundcraft_time::GridValue::Seconds(2.0)),
|
||||
("grid_lines", |e| e.grid_lines = !e.grid_lines),
|
||||
("main_counter", |e| e.main_counter = TimeFormat::Samples),
|
||||
("sub_counter", |e| e.sub_counter = None),
|
||||
("zoom_toggle", |e| e.zoom_toggle = Some(ZoomToggle { samples_per_px: 64.0, scroll: 0, heights: Vec::new() })),
|
||||
("rulers", |e| e.rulers.clear()),
|
||||
("link_timeline_edit", |e| e.link_timeline_edit = !e.link_timeline_edit),
|
||||
("link_track_edit", |e| e.link_track_edit = !e.link_track_edit),
|
||||
("insertion_follows_playback", |e| e.insertion_follows_playback = !e.insertion_follows_playback),
|
||||
("loop_playback", |e| e.loop_playback = !e.loop_playback),
|
||||
("pre_post_roll", |e| e.pre_post_roll = !e.pre_post_roll),
|
||||
("pre_roll", |e| e.pre_roll += 1),
|
||||
("post_roll", |e| e.post_roll += 1),
|
||||
("tab_to_transient", |e| e.tab_to_transient = !e.tab_to_transient),
|
||||
("automation_follows_edit", |e| e.automation_follows_edit = !e.automation_follows_edit),
|
||||
("markers_follow_edit", |e| e.markers_follow_edit = !e.markers_follow_edit),
|
||||
("layered_editing", |e| e.layered_editing = !e.layered_editing),
|
||||
("mirrored_midi", |e| e.mirrored_midi = !e.mirrored_midi),
|
||||
("click", |e| e.click = !e.click),
|
||||
("countoff", |e| e.countoff = !e.countoff),
|
||||
("countoff_bars", |e| e.countoff_bars += 1),
|
||||
("midi_merge", |e| e.midi_merge = !e.midi_merge),
|
||||
("record_mode", |e| e.record_mode = "destructive".into()),
|
||||
("loop_record", |e| e.loop_record = !e.loop_record),
|
||||
("quickpunch", |e| e.quickpunch = !e.quickpunch),
|
||||
("scrolling", |e| e.scrolling = "continuous".into()),
|
||||
("solo_mode", |e| e.solo_mode = "afl".into()),
|
||||
("pre_fader_metering", |e| e.pre_fader_metering = !e.pre_fader_metering),
|
||||
("delay_compensation", |e| e.delay_compensation = !e.delay_compensation),
|
||||
("keyboard_focus", |e| e.keyboard_focus = "midi".into()),
|
||||
("flags", |e| {
|
||||
e.flags.insert("test.flag".into());
|
||||
}),
|
||||
("values", |e| {
|
||||
e.values.insert("test.value".into(), 0.5);
|
||||
}),
|
||||
];
|
||||
for &(field, edit) in edits {
|
||||
let mut doc = saved.clone();
|
||||
edit(&mut doc.edit);
|
||||
check(&saved, &doc, false, field);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_and_media_changes_still_change_document_equality() {
|
||||
let mut saved = Session::default();
|
||||
let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let clip = Clip::audio(saved.new_clip_id(), "Clip", SourceId(900), 0, 0, 100);
|
||||
saved.track_mut(track).unwrap().playlist_mut().unwrap().clips.push(clip);
|
||||
let audio =
|
||||
Arc::new(soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 100]] }));
|
||||
saved.pool.insert(SourceId(900), audio);
|
||||
check(&saved, &saved.clone(), true, "shared decoded audio");
|
||||
let mut doc = saved.clone();
|
||||
doc.track_mut(track).unwrap().playlist_mut().unwrap().clips[0].gain_db = -6.0;
|
||||
check(&saved, &doc, false, "clip gain");
|
||||
let mut doc = saved.clone();
|
||||
doc.pool.remove(SourceId(900));
|
||||
check(&saved, &doc, false, "decoded audio removed");
|
||||
let mut doc = saved.clone();
|
||||
doc.folder = Some(std::path::PathBuf::from("session"));
|
||||
check(&saved, &doc, false, "session folder");
|
||||
let mut doc = saved.clone();
|
||||
doc.edit.values.insert("test.nan".into(), f64::NAN);
|
||||
assert_eq!(same_document(&doc, &doc), previous_same_document(&doc, &doc), "NaN keeps the previous PartialEq behavior");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_saved_zoom_keeps_the_previous_partial_eq_behavior() {
|
||||
let mut saved = Session::default();
|
||||
let doc = saved.clone();
|
||||
saved.edit.zoom.waveform_zoom = f32::NAN;
|
||||
assert!(!same_document(&saved, &doc));
|
||||
assert!(same_document(&doc, &saved));
|
||||
assert_eq!(same_document(&saved, &doc), previous_same_document(&saved, &doc));
|
||||
assert_eq!(same_document(&doc, &saved), previous_same_document(&doc, &saved));
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,26 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool)
|
||||
removed
|
||||
}
|
||||
|
||||
/// Remove the clips `ids` from every track. In Shuffle the later clips on each track close the holes the movable
|
||||
/// ones leave, as a Shuffle move lifts them (see [`lift_plan`]), so a crossfade partner keeps its material. Returns
|
||||
/// how many clips went.
|
||||
pub fn remove_clips(s: &mut Session, ids: &[ClipId], shuffle: bool) -> usize {
|
||||
let ids: HashSet<ClipId> = ids.iter().copied().collect();
|
||||
let mut n = 0;
|
||||
for track in s.tracks.iter().map(|t| t.id).collect::<Vec<_>>() {
|
||||
if shuffle {
|
||||
n += lift_all(s, track, &ids).len();
|
||||
}
|
||||
// Locked clips don't lift; they go without closing up, as everything does outside Shuffle.
|
||||
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
|
||||
let before = pl.clips.len();
|
||||
pl.clips.retain(|c| !ids.contains(&c.id));
|
||||
n += before - pl.clips.len();
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Copy the material inside `range` (clips trimmed to the range), relative to `range.start`.
|
||||
pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
|
||||
let t = TimeCtx::of(s);
|
||||
@@ -238,13 +258,16 @@ pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], lengt
|
||||
make_room(s, track, at, length)?;
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
let mut placed = Vec::new();
|
||||
for c in clips {
|
||||
let mut nc = c.clone();
|
||||
nc.id = s.new_clip_id();
|
||||
nc.start += at;
|
||||
ids.push(nc.id);
|
||||
place_clip(s, track, nc);
|
||||
placed.push((track, nc));
|
||||
}
|
||||
// Clear every destination first, so pasted clips that overlap (crossfades) keep each other.
|
||||
put_clips(s, placed);
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
@@ -523,8 +546,10 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
|
||||
}
|
||||
}
|
||||
let n = moving.len();
|
||||
// Limit the shift once, so the group stops at zero together and keeps its spacing.
|
||||
let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0));
|
||||
let placed = moving.into_iter().map(|(tid, mut c)| {
|
||||
c.start = c.start.saturating_add(delta).max(0);
|
||||
c.start = c.start.saturating_add(delta);
|
||||
(lands(dest, tid), c)
|
||||
});
|
||||
put_clips(s, placed.collect());
|
||||
@@ -794,8 +819,11 @@ pub fn trim_clip(s: &mut Session, id: ClipId, start: Option<Samples>, end: Optio
|
||||
lo = lo.max(0);
|
||||
}
|
||||
let file = |c: &Clip| {
|
||||
let start = c.start.saturating_sub(c.source_offset());
|
||||
material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(n).max(c.end())))
|
||||
// The mixer plays from `offset + rel / stretch`, so file samples span `stretch` times as many timeline samples.
|
||||
let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 };
|
||||
let scale = |n: Samples| if (stretch - 1.0).abs() >= 1e-9 { (n as f64 * stretch).round() as Samples } else { n };
|
||||
let start = c.start.saturating_sub(scale(c.source_offset()));
|
||||
material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(scale(n)).max(c.end())))
|
||||
};
|
||||
let t = TimeCtx::of(s);
|
||||
let mut c = old.clone();
|
||||
|
||||
@@ -159,6 +159,40 @@ fn clear_slip_and_shuffle() {
|
||||
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 2000, 3000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_clips_closes_each_gap_in_shuffle_only() {
|
||||
let split = |shuffle| {
|
||||
let (mut s, t) = session_with_clip();
|
||||
separate_at(&mut s, t, 2000);
|
||||
separate_at(&mut s, t, 3000);
|
||||
separate_at(&mut s, t, 4000);
|
||||
let ids: Vec<ClipId> = s.track(t).unwrap().clips().iter().map(|c| c.id).collect();
|
||||
// The first and third pieces: [1000, 2000) and [3000, 4000).
|
||||
assert_eq!(remove_clips(&mut s, &[ids[0], ids[2]], shuffle), 2);
|
||||
clips(&s, t)
|
||||
};
|
||||
assert_eq!(split(false), vec![(2000, 1000, 2000), (4000, 1000, 4000)]);
|
||||
assert_eq!(split(true), vec![(1000, 1000, 2000), (2000, 1000, 4000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_clips_in_shuffle_keeps_crossfade_partners_apart() {
|
||||
// `(start, end)` of each clip; the second one goes.
|
||||
let remove_second = |spans: &[(Samples, Samples)]| {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let ids: Vec<ClipId> = spans.iter().map(|_| s.new_clip_id()).collect();
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips = ids.iter().zip(spans).map(|(id, (a, b))| Clip::audio(*id, "c", SourceId(500), *a, *a, b - a)).collect();
|
||||
}
|
||||
assert_eq!(remove_clips(&mut s, &[ids[1]], true), 1);
|
||||
clips(&s, t).into_iter().map(|(start, len, _)| (start, start + len)).collect::<Vec<_>>()
|
||||
};
|
||||
// Crossfaded with the clip before: the hole starts where that one ends.
|
||||
assert_eq!(remove_second(&[(0, 1050), (950, 2000), (2000, 3000)]), vec![(0, 1050), (1050, 2050)]);
|
||||
// Crossfaded with the clip after: the hole ends where that one starts.
|
||||
assert_eq!(remove_second(&[(0, 1000), (1000, 2050), (1950, 3000), (4000, 5000)]), vec![(0, 1000), (1000, 2050), (3050, 4050)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_overwrites() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
@@ -343,3 +377,60 @@ fn delete_fades_leaves_unrelated_overlaps_alone() {
|
||||
let c = s.track(t).unwrap().clips();
|
||||
assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_keeps_crossfade_overlap() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let (a, b) = (s.new_clip_id(), s.new_clip_id());
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.clear();
|
||||
let mut ca = Clip::audio(a, "a", SourceId(500), 0, 1000, 2000);
|
||||
ca.fade_out = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
let mut cb = Clip::audio(b, "b", SourceId(500), 0, 2500, 2500);
|
||||
cb.fade_in = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
pl.clips.push(ca);
|
||||
pl.clips.push(cb);
|
||||
pl.sort();
|
||||
}
|
||||
let copied = copy_range(&s, t, Range::new(0, 6000));
|
||||
paste(&mut s, t, 20_000, &copied, 6000, false).unwrap();
|
||||
let pasted: Vec<_> =
|
||||
s.track(t).unwrap().clips().iter().filter(|c| c.start >= 20_000).map(|c| (c.start, c.length, c.fade_out.len, c.fade_in.len)).collect();
|
||||
assert_eq!(pasted, vec![(21_000, 2000, 500, 0), (22_500, 2500, 0, 500)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_clip_bounds_stretched_clip_to_its_file() {
|
||||
for (stretch, length, want) in [(0.5, 5000, 5000), (2.0, 1000, 20_000)] {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let id = {
|
||||
let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap();
|
||||
c.stretch = stretch;
|
||||
c.start = 0;
|
||||
c.length = length;
|
||||
if let ClipContent::Audio { offset, .. } = &mut c.content {
|
||||
*offset = 0;
|
||||
}
|
||||
c.id
|
||||
};
|
||||
let c = trim_clip(&mut s, id, None, Some(99_999), false).unwrap();
|
||||
assert_eq!(c.end(), want, "stretch {stretch}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_clip_start_bound_scales_the_offset_by_stretch() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let id = {
|
||||
let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap();
|
||||
c.stretch = 2.0;
|
||||
c.start = 10_000;
|
||||
c.length = 1000;
|
||||
if let ClipContent::Audio { offset, .. } = &mut c.content {
|
||||
*offset = 1000;
|
||||
}
|
||||
c.id
|
||||
};
|
||||
let c = trim_clip(&mut s, id, Some(0), None, false).unwrap();
|
||||
assert_eq!(c.start, 8000);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ pub fn session_text(e: &Engine) -> String {
|
||||
out.push_str(&format!("SESSION NAME:\t{}\nSAMPLE RATE:\t{}\nBIT DEPTH:\t{:?}\nTIMECODE FORMAT:\t{}\n# OF AUDIO TRACKS:\t{}\n# OF AUDIO CLIPS:\t{}\n# OF AUDIO FILES:\t{}\n\n",
|
||||
s.name, s.sample_rate.hz(), s.bit_depth, s.frame_rate.label(),
|
||||
s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).count(),
|
||||
s.tracks.iter().map(|t| t.clips().len()).sum::<usize>(), s.sources.len()));
|
||||
s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum::<usize>(), s.sources.len()));
|
||||
out.push_str("F I L E S I N S E S S I O N\nFilename\tLocation\n");
|
||||
for src in &s.sources {
|
||||
out.push_str(&format!("{}\t{}\n", src.name, src.path));
|
||||
@@ -156,6 +156,19 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn session_text_counts_only_audio_track_clips() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.execute("track.new", &json!({"kind": "midi"})).unwrap()["tracks"][0].clone();
|
||||
e.execute("midi.new_clip", &json!({"track": t, "start": 0, "length": 48000})).unwrap();
|
||||
let text = session_text(&e);
|
||||
assert!(text.contains("# OF AUDIO CLIPS:\t0\n"), "{text}");
|
||||
|
||||
let demo = crate::demo::demo_engine();
|
||||
let n: usize = demo.session().tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum();
|
||||
assert!(session_text(&demo).contains(&format!("# OF AUDIO CLIPS:\t{n}\n")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_report_whether_audio_is_loaded() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -598,6 +598,7 @@ fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
let mut src = std::fs::File::open(from)?;
|
||||
let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
|
||||
if let Err(err) = std::io::copy(&mut src, &mut dst) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
drop(dst);
|
||||
let _ = std::fs::remove_file(to);
|
||||
return Err(err);
|
||||
|
||||
@@ -2,17 +2,33 @@ use super::{ChannelFormat, Clip, ClipContent, Engine, EngineError, Result, Sampl
|
||||
|
||||
/// 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;
|
||||
/// Most tempo changes a MIDI file may bring into the tempo map: conversions walk the tempo events.
|
||||
pub(super) const MAX_MIDI_TEMPOS: usize = 10_000;
|
||||
/// Most synth tracks a MIDI file may create in the session.
|
||||
pub(super) const MAX_MIDI_TRACKS: usize = 512;
|
||||
|
||||
/// Import a Standard MIDI File as MIDI tracks (one per SMF track).
|
||||
pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result<Vec<TrackId>> {
|
||||
let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let s = e.session_mut();
|
||||
if tempo_map {
|
||||
// Each meter is placed by walking the ones before it.
|
||||
if smf.meters.len() > MAX_MIDI_METERS {
|
||||
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_METERS} meter changes")));
|
||||
}
|
||||
if smf.tempos.len() > MAX_MIDI_TEMPOS {
|
||||
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TEMPOS} tempo changes")));
|
||||
}
|
||||
}
|
||||
let tracks_to_import = smf.tracks.iter().filter(|tr| !tr.sequence.notes.is_empty() || !tr.sequence.ctrls.is_empty()).count();
|
||||
if tracks_to_import > MAX_MIDI_TRACKS {
|
||||
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TRACKS} tracks")));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
if tempo_map {
|
||||
for (tick, bpm) in &smf.tempos {
|
||||
if s.tempo.tempo_at_tick(*tick) == *bpm {
|
||||
continue;
|
||||
}
|
||||
let _ = s.tempo.set_tempo(*tick, *bpm);
|
||||
}
|
||||
for (tick, n, d) in &smf.meters {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::import::add_source;
|
||||
#[cfg(test)]
|
||||
use super::midi::{MAX_MIDI_METERS, import_midi_bytes};
|
||||
use super::midi::{MAX_MIDI_METERS, MAX_MIDI_TEMPOS, MAX_MIDI_TRACKS, import_midi_bytes};
|
||||
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
|
||||
@@ -196,6 +196,77 @@ mod name_tests {
|
||||
assert!(err.to_string().contains("meter changes"), "{err}");
|
||||
}
|
||||
|
||||
/// A MIDI file with more tempo changes than MAX_MIDI_TEMPOS is refused without mutating the session.
|
||||
#[test]
|
||||
fn midi_with_more_tempo_changes_than_performances_have_is_refused() {
|
||||
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
|
||||
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
|
||||
let smf = |n: usize| soundcraft_midi::Smf {
|
||||
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence: sequence.clone() }],
|
||||
tempos: (0..n as i64).map(|beat| (beat * 960, 100.0 + (beat % 40) as f64)).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut e = Engine::default();
|
||||
let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS + 1)).unwrap(), 0, true).unwrap_err();
|
||||
assert!(err.to_string().contains("tempo changes"), "{err}");
|
||||
assert_eq!((e.session().tempo.tempos().len(), e.session().tracks.len()), (1, 0), "nothing is imported");
|
||||
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS)).unwrap(), 0, true).unwrap();
|
||||
assert_eq!(e.session().tempo.tempos().len(), MAX_MIDI_TEMPOS);
|
||||
}
|
||||
|
||||
/// A MIDI file with more tracks than MAX_MIDI_TRACKS is refused without mutating the session.
|
||||
#[test]
|
||||
fn midi_with_more_tracks_than_supported_is_refused() {
|
||||
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
|
||||
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
|
||||
let smf = |n: usize| soundcraft_midi::Smf {
|
||||
tracks: (0..n).map(|i| soundcraft_midi::SmfTrack { name: format!("Tr {i}"), sequence: sequence.clone() }).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut e = Engine::default();
|
||||
let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS + 1)).unwrap(), 0, false).unwrap_err();
|
||||
assert!(err.to_string().contains("tracks"), "{err}");
|
||||
assert_eq!(e.session().tracks.len(), 0, "no tracks imported");
|
||||
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS)).unwrap(), 0, false).unwrap();
|
||||
assert_eq!(e.session().tracks.len(), MAX_MIDI_TRACKS);
|
||||
}
|
||||
|
||||
/// Consecutive equal tempo events in a MIDI file are collapsed to actual tempo changes.
|
||||
#[test]
|
||||
fn midi_collapses_equal_consecutive_tempos() {
|
||||
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
|
||||
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
|
||||
let smf = soundcraft_midi::Smf {
|
||||
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }],
|
||||
tempos: vec![(0, 120.0), (960, 120.0), (1920, 140.0), (2880, 140.0), (3840, 100.0)],
|
||||
..Default::default()
|
||||
};
|
||||
let mut e = Engine::default();
|
||||
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap();
|
||||
let tempos = e.session().tempo.tempos();
|
||||
assert_eq!(tempos.len(), 3);
|
||||
assert!((tempos[0].bpm - 120.0).abs() < 1e-3);
|
||||
assert!((tempos[1].bpm - 140.0).abs() < 1e-3);
|
||||
assert!((tempos[2].bpm - 100.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// A MIDI file replaces an existing session tempo event even if the MIDI file has consecutive equal tempos.
|
||||
#[test]
|
||||
fn midi_import_replaces_differing_existing_session_tempo() {
|
||||
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
|
||||
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
|
||||
let smf = soundcraft_midi::Smf {
|
||||
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }],
|
||||
tempos: vec![(0, 120.0), (960, 120.0)],
|
||||
..Default::default()
|
||||
};
|
||||
let mut e = Engine::default();
|
||||
e.session_mut().tempo.set_tempo(960, 80.0).unwrap();
|
||||
assert_eq!(e.session().tempo.tempo_at_tick(960), 80.0);
|
||||
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap();
|
||||
assert_eq!(e.session().tempo.tempo_at_tick(960), 120.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_sources_take_the_first_free_file_name() {
|
||||
let mut s = Session::default();
|
||||
|
||||
+139
-17
@@ -11,6 +11,7 @@ pub mod catalog_more;
|
||||
pub mod clip_group_file;
|
||||
pub mod cmd;
|
||||
pub mod demo;
|
||||
mod dirty;
|
||||
pub mod edit;
|
||||
pub mod file_access;
|
||||
pub mod inspect;
|
||||
@@ -23,9 +24,17 @@ use soundcraft_time::Samples;
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dirty::same_document;
|
||||
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use soundcraft_model as model;
|
||||
|
||||
/// Upper bound on journaled commands, so a long session cannot grow memory without limit.
|
||||
const MAX_JOURNAL_ENTRIES: usize = 10_000;
|
||||
/// Upper bound on a single journaled entry's params (serialized), so junk fields from MCP or the
|
||||
/// control channel cannot keep a huge blob alive for the life of the process.
|
||||
const MAX_JOURNAL_ENTRY_BYTES: usize = 64 * 1024; // 64 KiB
|
||||
|
||||
/// Shuts the third-party plugin hosts down, running the module exit of every plugin binary they
|
||||
/// loaded. Call it at the end of `main`, once every plugin instance (mixer, player) is gone: a
|
||||
/// plugin that has been used can crash when its module exit only runs from an exit handler.
|
||||
@@ -75,6 +84,8 @@ pub enum TransportRequest {
|
||||
/// Stop the old session's transport without applying stop-time edits to the new document.
|
||||
ResetSession,
|
||||
Play,
|
||||
/// Start playing at this position (or move there if already playing), ignoring the selection.
|
||||
PlayFrom(Samples),
|
||||
Stop,
|
||||
TogglePlay,
|
||||
Record,
|
||||
@@ -173,8 +184,9 @@ pub struct Engine {
|
||||
pub clipboard: Clipboard,
|
||||
pub transport_requests: Vec<TransportRequest>,
|
||||
pub transport: TransportStatus,
|
||||
/// Command journal (id, params) for macro recording / agents.
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Command journal (id, params) for macro recording / agents. Ring buffer: capped at
|
||||
/// [`MAX_JOURNAL_ENTRIES`] entries of up to [`MAX_JOURNAL_ENTRY_BYTES`] params each.
|
||||
pub journal: std::collections::VecDeque<(String, Value)>,
|
||||
/// Path of the session file, if saved.
|
||||
pub path: Option<String>,
|
||||
/// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).
|
||||
@@ -216,7 +228,7 @@ impl Engine {
|
||||
clipboard: Clipboard::default(),
|
||||
transport_requests: Vec::new(),
|
||||
transport: TransportStatus::default(),
|
||||
journal: Vec::new(),
|
||||
journal: std::collections::VecDeque::new(),
|
||||
path: None,
|
||||
revision: 1,
|
||||
dirty: false,
|
||||
@@ -419,7 +431,7 @@ impl Engine {
|
||||
self.push_undo(spec.label, before);
|
||||
}
|
||||
if spec.journal {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
self.journal_append(id, params);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
@@ -486,6 +498,16 @@ impl Engine {
|
||||
self.merge_key = None;
|
||||
}
|
||||
|
||||
/// Append a journaled command, clamping its params to [`MAX_JOURNAL_ENTRY_BYTES`] and the
|
||||
/// buffer to [`MAX_JOURNAL_ENTRIES`] entries (oldest are dropped).
|
||||
fn journal_append(&mut self, id: &str, params: &Value) {
|
||||
let clamped = (id.to_string(), journal_params(params));
|
||||
self.journal.push_back(clamped);
|
||||
if self.journal.len() > MAX_JOURNAL_ENTRIES {
|
||||
self.journal.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: execute with `{}` params.
|
||||
pub fn run(&mut self, id: &str) -> Result<Value> {
|
||||
self.execute(id, &json!({}))
|
||||
@@ -511,19 +533,6 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal apart from what changes without editing: the selection, the playhead and the zoom.
|
||||
fn same_document(saved: &Session, doc: &Session) -> bool {
|
||||
let mut doc = doc.clone();
|
||||
let (from, to) = (&saved.edit, &mut doc.edit);
|
||||
to.selection = from.selection;
|
||||
to.timeline_selection = from.timeline_selection;
|
||||
to.selected_tracks.clone_from(&from.selected_tracks);
|
||||
to.selected_clips.clone_from(&from.selected_clips);
|
||||
to.playhead = from.playhead;
|
||||
to.zoom = from.zoom;
|
||||
*saved == doc
|
||||
}
|
||||
|
||||
fn install_panic_hook() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
@@ -539,6 +548,92 @@ fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
|
||||
p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
/// The params to keep in the journal for a command: the value itself when it serializes within
|
||||
/// [`MAX_JOURNAL_ENTRY_BYTES`], or a truncation marker when it is too large or unparseable.
|
||||
///
|
||||
/// Serializes into a [`std::io::Write`] sink that fails as soon as the cap is exceeded, so an
|
||||
/// oversized `params` (a multi-MiB line from MCP or the control channel) is never fully
|
||||
/// serialized, cloned, or allocated just to be discarded.
|
||||
fn journal_params(params: &Value) -> Value {
|
||||
// Write through a bounded sink: serialization stops (WriteZero) the moment the cap is passed,
|
||||
// so an oversized value costs at most ~1× the cap, not its full serialized size.
|
||||
struct Bounded(Vec<u8>);
|
||||
impl std::io::Write for Bounded {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let room = MAX_JOURNAL_ENTRY_BYTES.saturating_sub(self.0.len());
|
||||
if room == 0 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap"));
|
||||
}
|
||||
let take = buf.len().min(room);
|
||||
self.0.extend_from_slice(&buf[..take]);
|
||||
if take < buf.len() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap"));
|
||||
}
|
||||
Ok(take)
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut sink = Bounded(Vec::new());
|
||||
// Fails with WriteZero as soon as the cap is exceeded, so the allocation stays bounded even
|
||||
// for a multi-MiB params.
|
||||
let ok = serde_json::to_writer(&mut sink, params).is_ok();
|
||||
if !ok {
|
||||
return json!({"__truncated__": true});
|
||||
}
|
||||
params.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod journal_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn journal_is_bounded_to_max_entries() {
|
||||
let mut e = Engine::default();
|
||||
// Calling the private append helper directly exercises the ring buffer without the cost
|
||||
// of cloning the whole document on 10,000+ `track.new` calls.
|
||||
for i in 0..MAX_JOURNAL_ENTRIES + 500 {
|
||||
let n = format!("t{i}");
|
||||
e.journal_append("track.new", &json!({"name": n}));
|
||||
}
|
||||
assert_eq!(e.journal.len(), MAX_JOURNAL_ENTRIES);
|
||||
// The oldest entries were dropped, the newest kept: the first remaining is the 501st pushed,
|
||||
// and the newest still there.
|
||||
let first = e.journal.front().map(|(_, p)| p["name"].as_str().unwrap_or_default());
|
||||
assert_eq!(first, Some("t500"));
|
||||
let last = e.journal.back().map(|(_, p)| p["name"].as_str().unwrap_or_default());
|
||||
let last_name = format!("t{}", MAX_JOURNAL_ENTRIES + 499);
|
||||
assert_eq!(last, Some(last_name.as_str()));
|
||||
assert_eq!(e.journal.back().map(|(id, _)| id.as_str()), Some("track.new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_params_are_truncated_in_the_journal() {
|
||||
let big = "x".repeat(MAX_JOURNAL_ENTRY_BYTES * 4);
|
||||
assert_eq!(journal_params(&json!({"name": big})), json!({"__truncated__": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_params_are_kept_whole() {
|
||||
let p = json!({"track": "Kick", "db": -6.0});
|
||||
assert_eq!(journal_params(&p), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_boundary_is_exact() {
|
||||
// A value just under the cap is kept whole; one just over is truncated. "Just" means the
|
||||
// bounded writer made at most ~two allocations of cap size, not a full multi-MiB buffer.
|
||||
// `{"n":"…"}` adds 9 bytes around the string.
|
||||
let small_str = MAX_JOURNAL_ENTRY_BYTES - 64;
|
||||
let small = json!({"n": "x".repeat(small_str)});
|
||||
assert_eq!(journal_params(&small), small);
|
||||
let big = json!({"n": "x".repeat(MAX_JOURNAL_ENTRY_BYTES)});
|
||||
assert_eq!(journal_params(&big), json!({"__truncated__": true}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
@@ -607,6 +702,33 @@ mod dirty_tests {
|
||||
e.undo();
|
||||
assert!(!e.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_state_follows_save_undo_and_redo_after_navigation() {
|
||||
let mut e = Engine::default();
|
||||
let track = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
|
||||
e.mark_clean();
|
||||
e.execute("mix.volume", &json!({"track": track.0, "db": -6.0})).unwrap();
|
||||
assert!(e.is_dirty());
|
||||
// A repeated check uses the revision cache.
|
||||
assert!(e.is_dirty());
|
||||
e.mark_clean();
|
||||
assert!(!e.is_dirty());
|
||||
e.execute("edit.select", &json!({"tracks": [track.0], "start": 100, "end": 200})).unwrap();
|
||||
e.execute("transport.locate", &json!({"at": 300})).unwrap();
|
||||
e.execute("view.zoom_in", &json!({})).unwrap();
|
||||
assert!(!e.is_dirty());
|
||||
assert!(e.undo());
|
||||
assert!(e.is_dirty(), "undoing away from the saved fader value is dirty");
|
||||
assert!(e.redo());
|
||||
assert!(!e.is_dirty(), "redoing to the saved fader value is clean despite navigation changes");
|
||||
e.execute("mix.volume", &json!({"track": track.0, "db": -12.0})).unwrap();
|
||||
assert!(e.is_dirty());
|
||||
assert!(e.undo());
|
||||
assert!(!e.is_dirty());
|
||||
assert!(e.redo());
|
||||
assert!(e.is_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+202
-12
@@ -13,9 +13,26 @@ use std::fmt::Write as _;
|
||||
const UNIT: i64 = TICKS_PER_QUARTER / 4;
|
||||
/// Upper bound on engraved measures, so a hostile session cannot make a huge document.
|
||||
const MAX_MEASURES: usize = 2000;
|
||||
/// Upper bound on output size, so a hostile session cannot make a huge document.
|
||||
const MAX_OUTPUT_BYTES: usize = 256 * 1024 * 1024; // 256 MiB
|
||||
/// Upper bound on items per measure, so a hostile session cannot make a huge document.
|
||||
const MAX_ITEMS_PER_MEASURE: usize = 128;
|
||||
/// Upper bound on pitches per chord, so a hostile session cannot make a huge document.
|
||||
const MAX_PITCHES_PER_CHORD: usize = 16;
|
||||
/// Upper bound on notes collected per part, before `fill` builds onsets and spans, so a hostile
|
||||
/// session cannot make `layout` accumulate an unbounded note vector.
|
||||
const MAX_NOTES_PER_PART: usize = 100_000;
|
||||
/// Note values a single notehead can show, in sixteenths (longest first).
|
||||
const VALUES: [u32; 8] = [16, 12, 8, 6, 4, 3, 2, 1];
|
||||
|
||||
/// The output has grown past the byte budget: refuse to grow it further.
|
||||
fn over_budget(buf: &str, budget: usize) -> Result<(), String> {
|
||||
if buf.len() > budget {
|
||||
return Err(format!("score too large: over {} bytes", budget));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Score Setup (File › Score Setup…). Stored in the session's edit flags/values.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ScoreSetup {
|
||||
@@ -93,6 +110,9 @@ pub fn layout(s: &Session, tracks: &[TrackId]) -> Vec<Part> {
|
||||
let base = s.tempo.samples_to_ticks(c.start, sr);
|
||||
let end = s.tempo.samples_to_ticks(c.start.saturating_add(c.length), sr);
|
||||
for n in &sequence.notes {
|
||||
if notes.len() >= MAX_NOTES_PER_PART {
|
||||
break;
|
||||
}
|
||||
let st = base.saturating_add(n.start);
|
||||
if st < base || st >= end || n.length <= 0 {
|
||||
continue;
|
||||
@@ -151,13 +171,21 @@ fn split_units(mut units: i64) -> Vec<u32> {
|
||||
}
|
||||
|
||||
fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec<Measure> {
|
||||
// Onsets → chords, each held until its longest note ends or the next onset.
|
||||
// Onsets → chords, each held until its longest *retained* note ends or the next onset. A pitch
|
||||
// the cap rejects must not extend the onset end.
|
||||
let mut onsets: std::collections::BTreeMap<i64, (i64, Vec<u8>)> = std::collections::BTreeMap::new();
|
||||
for (a, b, p) in notes {
|
||||
let e = onsets.entry(*a).or_insert((*b, Vec::new()));
|
||||
e.0 = e.0.max(*b);
|
||||
if !e.1.contains(p) {
|
||||
e.1.push(*p);
|
||||
let (ends, pitches) = onsets.entry(*a).or_insert((*b, Vec::new()));
|
||||
let new_pitch = !pitches.contains(p);
|
||||
if new_pitch && pitches.len() >= MAX_PITCHES_PER_CHORD {
|
||||
// Chord full and this pitch is absent: it is dropped and must not extend the onset end.
|
||||
continue;
|
||||
}
|
||||
// A retained pitch (already in the chord, or one the cap accepts) may extend the onset
|
||||
// end, even when it reappears with a longer duration.
|
||||
*ends = (*ends).max(*b);
|
||||
if new_pitch {
|
||||
pitches.push(*p);
|
||||
}
|
||||
}
|
||||
let starts: Vec<i64> = onsets.keys().copied().collect();
|
||||
@@ -196,10 +224,33 @@ fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec<Measure>
|
||||
}
|
||||
emit(from, to, ps, *a < *ms, *b > me, &mut items);
|
||||
cursor = to;
|
||||
// Cap items per measure to avoid pathological memory use.
|
||||
if items.len() > MAX_ITEMS_PER_MEASURE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if cursor < me {
|
||||
emit(cursor, me, &[], false, false, &mut items);
|
||||
}
|
||||
// Cap the item count without shortening the measure: whatever suffix the cap discards is
|
||||
// replaced with representable rests, so the bar's duration (and the SVG layout denominator)
|
||||
// stays right. Shrinking the item list further makes room for those rests.
|
||||
if items.len() > MAX_ITEMS_PER_MEASURE {
|
||||
let bar_len = me.saturating_sub(*ms);
|
||||
let mut keep = MAX_ITEMS_PER_MEASURE;
|
||||
loop {
|
||||
let kept: i64 = items.iter().take(keep).map(|i| i64::from(i.units)).sum();
|
||||
let rests = split_units(bar_len.saturating_sub(kept));
|
||||
if keep.saturating_add(rests.len()) <= MAX_ITEMS_PER_MEASURE || keep == 0 {
|
||||
items.truncate(keep);
|
||||
items.extend(rests.into_iter().map(|units| Item { pitches: Vec::new(), units, tie_start: false, tie_stop: false }));
|
||||
break;
|
||||
}
|
||||
keep -= 1;
|
||||
}
|
||||
// Safety net for a hostile meter so wide that even whole-bar rests exceed the cap.
|
||||
items.truncate(MAX_ITEMS_PER_MEASURE);
|
||||
}
|
||||
measures.push(Measure { numerator: *num, denominator: *den, items });
|
||||
}
|
||||
measures
|
||||
@@ -214,7 +265,13 @@ fn spell(pitch: u8) -> (char, i32, i32) {
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
s.chars()
|
||||
.filter(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}'))
|
||||
.collect::<String>()
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn note_type(units: u32) -> (&'static str, bool) {
|
||||
@@ -231,11 +288,20 @@ fn note_type(units: u32) -> (&'static str, bool) {
|
||||
}
|
||||
|
||||
/// MusicXML 4.0 (partwise) for `parts`.
|
||||
pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
|
||||
pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result<String, String> {
|
||||
musicxml_budgeted(parts, setup, MAX_OUTPUT_BYTES)
|
||||
}
|
||||
|
||||
/// [`musicxml`] with an explicit byte budget (kept separate so tests can pass a tiny budget
|
||||
/// instead of building a 256 MiB score).
|
||||
fn musicxml_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<String, String> {
|
||||
let mut x = String::new();
|
||||
x.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n");
|
||||
x.push_str("<!DOCTYPE score-partwise PUBLIC \"-//Recordare//DTD MusicXML 4.0 Partwise//EN\" \"http://www.musicxml.org/dtds/partwise.dtd\">\n");
|
||||
x.push_str("<score-partwise version=\"4.0\">\n");
|
||||
// The header (and an oversized title/composer, escaped) can itself exceed the budget even with
|
||||
// no measures, so check before the per-measure loop too.
|
||||
over_budget(&x, budget)?;
|
||||
let _ = writeln!(x, " <work><work-title>{}</work-title></work>", xml_escape(&setup.title));
|
||||
x.push_str(" <identification>\n");
|
||||
if !setup.composer.is_empty() {
|
||||
@@ -246,6 +312,7 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
|
||||
let _ = writeln!(x, " <score-part id=\"P{}\"><part-name>{}</part-name></score-part>", i + 1, xml_escape(&p.name));
|
||||
}
|
||||
x.push_str(" </part-list>\n");
|
||||
over_budget(&x, budget)?;
|
||||
for (i, p) in parts.iter().enumerate() {
|
||||
let _ = writeln!(x, " <part id=\"P{}\">", i + 1);
|
||||
let mut meter = (0, 0);
|
||||
@@ -312,11 +379,13 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
|
||||
}
|
||||
}
|
||||
x.push_str(" </measure>\n");
|
||||
over_budget(&x, budget)?;
|
||||
}
|
||||
x.push_str(" </part>\n");
|
||||
}
|
||||
x.push_str("</score-partwise>\n");
|
||||
x
|
||||
over_budget(&x, budget)?;
|
||||
Ok(x)
|
||||
}
|
||||
|
||||
/// Staff position of `pitch` in half-spaces above the bottom line (treble E4 / bass G2).
|
||||
@@ -328,7 +397,13 @@ fn staff_step(pitch: u8, bass: bool) -> i32 {
|
||||
}
|
||||
|
||||
/// An engraved score as a printable SVG page (A4 portrait width, as tall as needed).
|
||||
pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
|
||||
pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result<String, String> {
|
||||
svg_budgeted(parts, setup, MAX_OUTPUT_BYTES)
|
||||
}
|
||||
|
||||
/// [`svg`] with an explicit byte budget (kept separate so tests can pass a tiny budget instead of
|
||||
/// building a 256 MiB score).
|
||||
fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<String, String> {
|
||||
const W: f64 = 794.0;
|
||||
const MARGIN: f64 = 48.0;
|
||||
const SP: f64 = 7.0; // staff space
|
||||
@@ -350,7 +425,9 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
|
||||
let _ = writeln!(o, "<text x=\"{}\" y=\"56\" font-size=\"24\" text-anchor=\"middle\">{}</text>", W / 2.0, xml_escape(&setup.title));
|
||||
if !setup.composer.is_empty() {
|
||||
let _ = writeln!(o, "<text x=\"{}\" y=\"84\" font-size=\"13\" text-anchor=\"end\">{}</text>", W - MARGIN, xml_escape(&setup.composer));
|
||||
over_budget(&o, budget)?;
|
||||
}
|
||||
over_budget(&o, budget)?;
|
||||
let bar_w = (staff_x1 - staff_x0) / per as f64;
|
||||
for sys in 0..systems {
|
||||
let y_sys = top + sys as f64 * sys_h;
|
||||
@@ -462,7 +539,13 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
|
||||
let _ = writeln!(o, "<path d=\"M{:.1} {y:.1} q 8 5 16 0\" fill=\"none\" stroke=\"black\" stroke-width=\"0.9\"/>", x + 3.0);
|
||||
}
|
||||
}
|
||||
// Check each measure, so a run of rest-only or missing measures that only draws
|
||||
// bar lines cannot grow the output past the budget untouched.
|
||||
over_budget(&o, budget)?;
|
||||
}
|
||||
// The staff markup (lines, clef, track names) itself grows with the part count even
|
||||
// without any items, so check once per staff too.
|
||||
over_budget(&o, budget)?;
|
||||
}
|
||||
// System bracket.
|
||||
let y_last = y_sys + (parts.len().max(1) - 1) as f64 * STAFF_GAP + 4.0 * SP;
|
||||
@@ -472,9 +555,11 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
|
||||
staff_x0 - 30.0,
|
||||
staff_x0 - 30.0
|
||||
);
|
||||
over_budget(&o, budget)?;
|
||||
}
|
||||
o.push_str("</svg>\n");
|
||||
o
|
||||
over_budget(&o, budget)?;
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn rest(o: &mut String, x: f64, y0: f64, sp: f64, units: u32) {
|
||||
@@ -552,10 +637,115 @@ mod tests {
|
||||
let parts = layout(e.session(), &[]);
|
||||
assert!(!parts.is_empty(), "demo has MIDI tracks");
|
||||
let setup = ScoreSetup::from_session(e.session());
|
||||
let x = musicxml(&parts, &setup);
|
||||
let x = musicxml(&parts, &setup).expect("demo musicxml fits");
|
||||
assert!(x.contains("<score-partwise") && x.contains("<pitch>") && x.trim_end().ends_with("</score-partwise>"));
|
||||
assert_eq!(x.matches("<measure ").count(), x.matches("</measure>").count());
|
||||
let s = svg(&parts, &setup);
|
||||
let s = svg(&parts, &setup).expect("demo svg fits");
|
||||
assert!(s.starts_with("<svg") && s.contains("<ellipse"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_bounded_by_byte_budget() {
|
||||
// A part with many notes must not produce an unbounded document.
|
||||
let bars: Vec<_> = (0..100).map(|i| (i * 16, 16, 4, 4)).collect();
|
||||
let mut notes = Vec::new();
|
||||
for i in 0..2000 {
|
||||
notes.push((i * 2, i * 2 + 1, 60 + (i % 24) as u8));
|
||||
}
|
||||
let measures = fill(&bars, ¬es);
|
||||
let part = Part { name: "dense".into(), bass: false, measures };
|
||||
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 4, show_track_names: false };
|
||||
let s = svg(&[part], &setup);
|
||||
assert!(s.is_ok(), "dense but bounded score fits");
|
||||
let s = s.unwrap();
|
||||
assert!(s.len() <= MAX_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_score_is_refused_not_aborted() {
|
||||
// Many dense parts drive the output past the byte budget: the call must return an error,
|
||||
// not grow a multi-GB string or abort. Use the budgeted entry points with a tiny budget so
|
||||
// the test exercises the over-budget path without allocating 256 MiB of score, and covers
|
||||
// both the per-measure checks and the header/staff-only growth.
|
||||
let bars: Vec<_> = (0..100).map(|i| (i * 16, 16, 4, 4)).collect();
|
||||
let mut notes = Vec::new();
|
||||
for i in 0..2000 {
|
||||
let onset = i * 2;
|
||||
notes.push((onset, onset + 1, 60 + (i % 24) as u8));
|
||||
}
|
||||
let measures = fill(&bars, ¬es);
|
||||
let part = Part { name: "dense".into(), bass: false, measures };
|
||||
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 1, show_track_names: true };
|
||||
// Enough copies of the dense part to exceed a tiny budget.
|
||||
let parts: Vec<Part> = (0..64).map(|i| Part { name: format!("part{i}"), ..part.clone() }).collect();
|
||||
let tiny = 1024;
|
||||
let err = svg_budgeted(&parts, &setup, tiny).unwrap_err();
|
||||
assert!(err.contains("score too large"), "{err}");
|
||||
|
||||
// MusicXML needs a budget that clears the header/part-list but trips in the measures, so
|
||||
// the per-measure check is what refuses the document (this test used to fail on the
|
||||
// part-list check alone).
|
||||
let boundless = usize::MAX;
|
||||
// The header plus part-list exactly as `parse` writes it, but with no measures: that is
|
||||
// the size a budget must clear before the per-measure check is reached.
|
||||
let header_parts: Vec<Part> = parts.iter().map(|p| Part { name: p.name.clone(), bass: p.bass, measures: Vec::new() }).collect();
|
||||
let header = musicxml_budgeted(&header_parts, &setup, boundless).map_or(0, |x| x.len());
|
||||
let full = musicxml_budgeted(&parts, &setup, boundless).map_or(0, |x| x.len());
|
||||
assert!(header > 0, "the bare header fits any budget");
|
||||
assert!(full > header, "the measures add output ({full} vs {header})");
|
||||
// A budget above the bare header but below the completed document must be refused by the
|
||||
// per-measure check.
|
||||
let between = header.saturating_add((full.saturating_sub(header)) / 2);
|
||||
assert!(between > header && between < full, "budget sits between header and document");
|
||||
// The header alone fits that budget, so the refusal below comes from the measures.
|
||||
assert!(musicxml_budgeted(&header_parts, &setup, between).is_ok(), "header fits the mid budget");
|
||||
let err = musicxml_budgeted(&parts, &setup, between).unwrap_err();
|
||||
assert!(err.contains("score too large"), "{err}");
|
||||
// And a header that does not fit is refused before any measure is built.
|
||||
let err = musicxml_budgeted(&parts, &setup, tiny).unwrap_err();
|
||||
assert!(err.contains("score too large"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_cap_keeps_the_measure_duration() {
|
||||
// A 64/1 bar (256 sixteenths) with one note per sixteenth needs 256 items: over the cap.
|
||||
// What the cap discards must come back as rests, so the bar still fills completely and the
|
||||
// SVG layout denominator stays right.
|
||||
let bars = vec![(0i64, 256i64, 64u32, 4u32)];
|
||||
let notes: Vec<(i64, i64, u8)> = (0..256).map(|i| (i, i + 1, 60)).collect();
|
||||
let measures = fill(&bars, ¬es);
|
||||
assert_eq!(measures.len(), 1);
|
||||
let m = &measures[0];
|
||||
assert!(m.items.len() <= MAX_ITEMS_PER_MEASURE, "capped at {MAX_ITEMS_PER_MEASURE}");
|
||||
let total: u32 = m.items.iter().map(|i| i.units).sum();
|
||||
assert_eq!(total, 256, "the bar still lasts 256 sixteenths");
|
||||
assert!(m.items.iter().any(|i| i.pitches.is_empty()), "the discarded suffix became rests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_title_is_refused_by_the_header_check() {
|
||||
// An oversized part name/title must be caught by the header check even with no measures:
|
||||
// the escaped copy alone exceeds the budget. (A 54 MiB title blows past 64 KiB of escaped
|
||||
// text without touching the per-measure loop.)
|
||||
let setup = ScoreSetup { title: "&".repeat(64 * 1024), composer: "c".repeat(64 * 1024), bars_per_system: 4, show_track_names: false };
|
||||
let err = musicxml_budgeted(&[], &setup, 1024).unwrap_err();
|
||||
assert!(err.contains("score too large"), "{err}");
|
||||
let err = svg_budgeted(&[], &setup, 1024).unwrap_err();
|
||||
assert!(err.contains("score too large"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_drop_characters_xml_forbids() {
|
||||
let e = crate::demo::demo_engine();
|
||||
let mut parts = layout(e.session(), &[]);
|
||||
parts[0].name = "Lead\u{1}1\u{FFFE}".to_string();
|
||||
let mut setup = ScoreSetup::from_session(e.session());
|
||||
setup.title = "T\u{2}itle".to_string();
|
||||
setup.composer = "C\u{3}omp".to_string();
|
||||
for out in [musicxml(&parts, &setup).expect("musicxml fits"), svg(&parts, &setup).expect("svg fits")] {
|
||||
let bad: Vec<char> =
|
||||
out.chars().filter(|c| (*c < ' ' && !matches!(c, '\n' | '\t' | '\r')) || matches!(c, '\u{FFFE}' | '\u{FFFF}')).collect();
|
||||
assert!(bad.is_empty(), "forbidden characters: {bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +299,22 @@ fn shuffle_clear_moves_automation_with_the_clips() {
|
||||
assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]);
|
||||
}
|
||||
|
||||
/// Clearing selected clips by id closes their gaps in Shuffle, as clearing their ranges does, and moves the
|
||||
/// automation with the clips.
|
||||
#[test]
|
||||
fn shuffle_clear_by_clip_id_closes_each_gap() {
|
||||
for (mode, want, want_points) in [
|
||||
("shuffle", [(0, 10_000), (20_000, 10_000)], [0, 9_000, 20_000, 29_000]),
|
||||
("slip", [(0, 10_000), (40_000, 10_000)], [0, 9_000, 40_000, 49_000]),
|
||||
] {
|
||||
let (mut e, t, ids) = long(mode, &[(0, 10_000, 0), (20_000, 10_000, 0), (30_000, 10_000, 0), (40_000, 10_000, 0)]);
|
||||
set_points(&mut e, t, &[0, 9_000, 40_000, 49_000]);
|
||||
run(&mut e, "edit.clear", json!({"clips": [ids[1].0, ids[2].0], "tracks": [t.0], "start": 0}));
|
||||
assert_eq!(clips(&e, t), want, "{mode}");
|
||||
assert_eq!(points(&e, t), want_points, "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() {
|
||||
let (mut e, t, [a, _]) = engine("relative_grid");
|
||||
@@ -413,3 +429,30 @@ fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() {
|
||||
run(&mut e, "edit.mode", json!({"mode": "grid"}));
|
||||
assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err());
|
||||
}
|
||||
|
||||
fn selection_marker(e: &mut Engine, start: i64, end: i64) -> usize {
|
||||
run(e, "markers.add", json!({"kind": "selection", "start": start, "end": end}));
|
||||
e.session().markers.len() - 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_and_insert_time_move_the_end_of_a_spanning_selection_memory_location() {
|
||||
let (mut e, _, _) = engine("slip");
|
||||
let a = selection_marker(&mut e, 1000, 5000);
|
||||
let b = selection_marker(&mut e, 1000, 2500);
|
||||
run(&mut e, "event.cut_time", json!({"start": 2000, "end": 3000}));
|
||||
let m = &e.session().markers;
|
||||
assert_eq!((m[a].start, m[a].end), (1000, 4000));
|
||||
assert_eq!((m[b].start, m[b].end), (1000, 2000));
|
||||
run(&mut e, "event.insert_time", json!({"start": 2000, "length": 1000}));
|
||||
let m = &e.session().markers;
|
||||
assert_eq!((m[a].start, m[a].end), (1000, 5000));
|
||||
assert_eq!((m[b].start, m[b].end), (1000, 2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_group_left_past_zero_keeps_its_spacing() {
|
||||
let (mut e, t, ids) = long("slip", &[(500, 100, 0), (2000, 100, 0)]);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": -1000}));
|
||||
assert_eq!(clips(&e, t), [(0, 100), (1500, 100)]);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ mod hostile_params;
|
||||
mod hostile_sessions;
|
||||
mod long_renders;
|
||||
mod midi_offset;
|
||||
mod min_secs_with_timecode_counter;
|
||||
mod mix_required_params;
|
||||
mod random_edits;
|
||||
mod recording;
|
||||
mod session_transport;
|
||||
mod split_into_mono;
|
||||
mod time_key_signatures;
|
||||
mod track_name_collisions;
|
||||
mod trim_to_selection;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
|
||||
#[test]
|
||||
fn min_secs_string_is_not_misread_as_timecode_when_the_counter_is_timecode() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap();
|
||||
e.execute("edit.select", &json!({"start": "0:12.500", "end": "0:20.000"})).unwrap();
|
||||
let sel = e.session().edit.selection;
|
||||
assert_eq!((sel.start, sel.end), (600_000, 960_000));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ChannelFormat, Session, TrackKind};
|
||||
|
||||
fn engine_with_keys() -> Engine {
|
||||
let mut session = Session::default();
|
||||
session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let mut engine = Engine::new(session);
|
||||
for (at, key) in [(10, "C major"), (48000, "D major"), (100000, "E major")] {
|
||||
engine.execute("event.add_key_change", &json!({"key": key, "at": at})).unwrap();
|
||||
}
|
||||
engine
|
||||
}
|
||||
|
||||
fn keys(engine: &Engine) -> Vec<(u64, String)> {
|
||||
engine.session().key_signatures.iter().map(|(at, k)| (*at as u64, k.clone())).collect()
|
||||
}
|
||||
|
||||
fn k(at: u64, key: &str) -> (u64, String) {
|
||||
(at, key.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_time_moves_key_signatures() {
|
||||
let mut engine = engine_with_keys();
|
||||
let original = keys(&engine);
|
||||
engine.execute("event.insert_time", &json!({"start": 48000, "length": 1000})).unwrap();
|
||||
assert_eq!(keys(&engine), vec![k(10, "C major"), k(49000, "D major"), k(101000, "E major")]);
|
||||
engine.undo();
|
||||
assert_eq!(keys(&engine), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_time_removes_and_moves_key_signatures() {
|
||||
let mut engine = engine_with_keys();
|
||||
let original = keys(&engine);
|
||||
engine.execute("event.cut_time", &json!({"start": 20, "end": 60000})).unwrap();
|
||||
assert_eq!(keys(&engine), vec![k(10, "C major"), k(40020, "E major")]);
|
||||
engine.undo();
|
||||
assert_eq!(keys(&engine), original);
|
||||
}
|
||||
@@ -178,9 +178,10 @@ pub fn change_velocity_with_seed(notes: &mut [Note], op: VelocityOp, seed: u64)
|
||||
VelocityOp::Ramp { from, to } => {
|
||||
let first = notes.iter().map(|n| n.start).min().unwrap_or(0);
|
||||
let last = notes.iter().map(|n| n.start).max().unwrap_or(0);
|
||||
let span = last.saturating_sub(first) as f64;
|
||||
let span = (i128::from(last) - i128::from(first)) as f64;
|
||||
for n in notes.iter_mut() {
|
||||
let t = if span > 0.0 { n.start.saturating_sub(first) as f64 / span } else { 0.0 };
|
||||
let offset = (i128::from(n.start) - i128::from(first)) as f64;
|
||||
let t = if span > 0.0 { offset / span } else { 0.0 };
|
||||
let v = f64::from(from) + (f64::from(to) - f64::from(from)) * t;
|
||||
n.velocity = clamp_vel(round_i64(v));
|
||||
}
|
||||
|
||||
@@ -106,6 +106,13 @@ fn transpose_clamps() {
|
||||
assert!(notes.iter().all(|n| n.pitch == 127));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ramp_full_tick_range() {
|
||||
let mut notes = vec![n(60, i64::MIN, 1), n(60, 0, 1), n(60, i64::MAX, 1)];
|
||||
change_velocity(&mut notes, VelocityOp::Ramp { from: 1, to: 127 });
|
||||
assert_eq!(notes.iter().map(|n| n.velocity).collect::<Vec<_>>(), vec![1, 64, 127]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn velocity_ops() {
|
||||
let base = vec![n(60, 0, 1), n(60, 100, 1), n(60, 200, 1)];
|
||||
|
||||
@@ -65,7 +65,7 @@ pub(crate) fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
|
||||
&& let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0)))
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
return l.value_at(pos, base);
|
||||
return l.value_at(pos, base).clamp(soundcraft_dsp::MIN_DB, crate::SEND_LEVEL_MAX_DB);
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ impl ClipFx {
|
||||
c.set_param(p, v);
|
||||
}
|
||||
} else if k == "gain" {
|
||||
self.gain_db = v;
|
||||
self.gain_db = if v.is_nan() { 0.0 } else { v.clamp(soundcraft_dsp::MIN_DB, crate::CLIP_FX_MAX_GAIN_DB) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,11 @@ use strip::{DelayLine, MAX_LATENCY};
|
||||
use threads::run_strips;
|
||||
pub use threads::{mark_audio_thread, on_audio_thread};
|
||||
|
||||
/// Top of the clip effects gain slider; a stored or wired gain is clamped to it (#884).
|
||||
pub const CLIP_FX_MAX_GAIN_DB: f32 = 24.0;
|
||||
/// Top of a send fader, the same +12 dB `AutoParam::SendLevel` ranges up to (#884).
|
||||
pub const SEND_LEVEL_MAX_DB: f32 = 12.0;
|
||||
|
||||
/// Default (stereo) main mix width. The actual width is [`main_channels`].
|
||||
pub const MAIN_CHANNELS: usize = 2;
|
||||
|
||||
|
||||
@@ -224,6 +224,15 @@ fn sends_feed_aux_via_bus() {
|
||||
assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_level_automation_is_capped_at_the_fader_top() {
|
||||
// #884: a send lane point past +12 dB (pushed without `set_point`'s clamp) reads as +12.
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.lane_mut(&AutoParam::SendLevel(0)).points.push(soundcraft_model::AutomationPoint { at: 0, value: 500.0 });
|
||||
assert_eq!(send_level(s.track(t).unwrap(), 0, 0.0, 100), SEND_LEVEL_MAX_DB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bus_is_the_bus_not_the_main_mix() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
@@ -1187,3 +1196,15 @@ fn apply_matrix_span_matches_the_plain_per_sample_sum() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gain_ramp_matches_converting_both_ends_every_sample() {
|
||||
for (db0, db1, n) in [(-6.0f32, 3.0f32, 512usize), (0.0, 0.0, 1), (-144.0, 0.0, 300), (2.5, -60.0, 0), (f32::NAN, -3.0, 64), (-200.0, 12.0, 7)] {
|
||||
let ramp = crate::automation::GainRamp::new(db0, db1, n);
|
||||
let (g0, g1) = (db_to_gain(db0), db_to_gain(db1));
|
||||
for i in 0..=n {
|
||||
let want = if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) };
|
||||
assert_eq!(ramp.at(i).to_bits(), want.to_bits(), "{db0} -> {db1} dB, sample {i} of {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,8 @@ impl AutoParam {
|
||||
}
|
||||
/// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`.
|
||||
pub fn parse(s: &str) -> Option<AutoParam> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
let orig = s.trim();
|
||||
let s = orig.to_ascii_lowercase();
|
||||
match s.as_str() {
|
||||
"volume" | "vol" => return Some(AutoParam::Volume),
|
||||
"pan" | "pan1" => return Some(AutoParam::Pan(0)),
|
||||
@@ -100,6 +101,8 @@ impl AutoParam {
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("plugin:") {
|
||||
let (slot, param) = rest.split_once(':')?;
|
||||
// ASCII lowercasing keeps byte offsets, so take the param from the original.
|
||||
let param = orig.get(orig.len() - param.len()..)?;
|
||||
return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() });
|
||||
}
|
||||
None
|
||||
@@ -158,7 +161,10 @@ impl AutomationLane {
|
||||
if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) {
|
||||
return a.value;
|
||||
}
|
||||
a.value + (b.value - a.value) * ((at - a.at) as f32 / (b.at - a.at) as f32)
|
||||
// Widen so far-apart breakpoints cannot overflow i64.
|
||||
let span = i128::from(b.at) - i128::from(a.at);
|
||||
let off = i128::from(at) - i128::from(a.at);
|
||||
a.value + (b.value - a.value) * (off as f64 / span as f64) as f32
|
||||
}
|
||||
(Some(a), _) => a.value,
|
||||
_ => default,
|
||||
@@ -255,6 +261,26 @@ impl AutomationLane {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_keeps_plugin_param_case() {
|
||||
let p = AutoParam::Plugin { slot: 0, param: "Cutoff".into() };
|
||||
assert_eq!(AutoParam::parse("plugin:a:Cutoff"), Some(p));
|
||||
assert_eq!(AutoParam::parse("Plugin:B:Mix"), Some(AutoParam::Plugin { slot: 1, param: "Mix".into() }));
|
||||
assert_eq!(AutoParam::parse("Volume"), Some(AutoParam::Volume));
|
||||
assert_eq!(AutoParam::parse("SEND_B_LEVEL"), Some(AutoParam::SendLevel(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_at_far_apart_points_does_not_overflow() {
|
||||
let mut l = AutomationLane::new(AutoParam::Volume);
|
||||
l.set_point(-1, 0.0);
|
||||
l.set_point(i64::MAX, -10.0);
|
||||
let v = l.value_at(5, 0.0);
|
||||
assert!(v.is_finite() && (-10.0..=0.0).contains(&v));
|
||||
let w = l.value_at(i64::MAX - 1, 0.0);
|
||||
assert!(w.is_finite() && (-10.0..=0.0).contains(&w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_label_max_does_not_overflow() {
|
||||
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
|
||||
|
||||
@@ -626,6 +626,8 @@ impl Session {
|
||||
}
|
||||
for l in &mut t.automation {
|
||||
l.points.retain(|p| p.value.is_finite());
|
||||
let (lo, hi, _) = l.param.range();
|
||||
l.points.iter_mut().for_each(|p| p.value = p.value.clamp(lo, hi));
|
||||
l.points.iter_mut().for_each(|p| bound(&mut p.at));
|
||||
l.points.sort_by_key(|p| p.at);
|
||||
}
|
||||
@@ -905,6 +907,22 @@ mod tests {
|
||||
assert!(s.next_id > t.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_automation_is_clamped_to_its_range() {
|
||||
// #884: a session file can carry points outside a lane's range; load clamps them.
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] {
|
||||
tr.lane_mut(&p).points.push(crate::AutomationPoint { at: 0, value: 500.0 });
|
||||
}
|
||||
let s = Session::from_json(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
let tr = s.track(t).unwrap();
|
||||
for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] {
|
||||
assert_eq!(tr.lane(&p).unwrap().points[0].value, 12.0, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_auto_name_scheme_leaves_room_for_its_number() {
|
||||
let mut scheme = crate::AutoName { name: "n".repeat(300), suffix: "s".repeat(300), ..Default::default() };
|
||||
|
||||
@@ -115,7 +115,19 @@ pub fn parse_position(
|
||||
let v = sr.samples(m * 60.0 + s);
|
||||
Ok(if neg { -v } else { v })
|
||||
}
|
||||
TimeFormat::Timecode => Ok(Timecode::parse(t)?.to_samples(rate, sr).saturating_sub(tc_start)),
|
||||
TimeFormat::Timecode => {
|
||||
let tc = Timecode::parse(t)?;
|
||||
if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) {
|
||||
return Err(err());
|
||||
}
|
||||
if rate.is_drop() && tc.seconds == 0 && tc.minutes % 10 != 0 {
|
||||
let dropped = if rate.nominal() >= 60 { 4 } else { 2 };
|
||||
if tc.frames < dropped {
|
||||
return Err(err());
|
||||
}
|
||||
}
|
||||
Ok(tc.to_samples(rate, sr).saturating_sub(tc_start))
|
||||
}
|
||||
TimeFormat::FeetFrames => {
|
||||
let (ft, fr) = t.split_once('+').ok_or_else(err)?;
|
||||
let ft: i64 = ft.trim().parse().map_err(|_| err())?;
|
||||
@@ -207,6 +219,20 @@ mod tests {
|
||||
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_skipped_labels_are_rejected() {
|
||||
let (sr, map, _) = ctx();
|
||||
for (rate, skipped) in [
|
||||
(FrameRate::Fps29_97Drop, &["00:01:00;00", "00:01:00;01"][..]),
|
||||
(FrameRate::Fps59_94Drop, &["00:01:00;00", "00:01:00;01", "00:01:00;02", "00:01:00;03"][..]),
|
||||
] {
|
||||
for text in skipped {
|
||||
assert!(parse_position(text, TimeFormat::Timecode, sr, &map, rate, 0, 0).is_err(), "accepted skipped label {text} at {rate:?}");
|
||||
}
|
||||
assert!(parse_position("00:10:00;00", TimeFormat::Timecode, sr, &map, rate, 0, 0).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_lengths() {
|
||||
let (sr, map, rate) = ctx();
|
||||
|
||||
@@ -122,7 +122,7 @@ impl GridValue {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
let t0 = map.samples_to_ticks(at, sr);
|
||||
let dt = Self::note_ticks(value, dotted, triplet, map, t0);
|
||||
(map.tick_to_samples(t0 + dt, sr) - map.tick_to_samples(t0, sr)).max(1)
|
||||
(map.tick_to_samples(t0.saturating_add(dt), sr).saturating_sub(map.tick_to_samples(t0, sr))).max(1)
|
||||
}
|
||||
GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1),
|
||||
GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1),
|
||||
@@ -320,4 +320,11 @@ mod tests {
|
||||
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
|
||||
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_at_extreme_position_does_not_overflow() {
|
||||
let sr = SampleRate::new(8000).unwrap();
|
||||
let map = TempoMap::new(1000.0, 4, 4).unwrap();
|
||||
let _ = GridValue::default().snap(i64::MAX, sr, &map, FrameRate::Fps30);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,10 +251,10 @@ impl TempoMap {
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
let next_tick = self.meters.get(i + 1).map(|n| n.tick);
|
||||
let tpb = m.ticks_per_bar();
|
||||
let bars_in_seg = next_tick.map(|nt| ((nt - cur_tick) + tpb - 1) / tpb);
|
||||
let bars_in_seg = next_tick.map(|nt| ceil_div(nt.saturating_sub(cur_tick), tpb));
|
||||
match bars_in_seg {
|
||||
Some(n) if bar >= cur_bar + n => {
|
||||
cur_bar += n;
|
||||
Some(n) if bar >= cur_bar.saturating_add(n) => {
|
||||
cur_bar = cur_bar.saturating_add(n);
|
||||
cur_tick = next_tick.unwrap_or(cur_tick);
|
||||
}
|
||||
_ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)),
|
||||
@@ -284,7 +284,7 @@ impl TempoMap {
|
||||
if let Some(n) = self.meters.get(i + 1)
|
||||
&& tick >= n.tick
|
||||
{
|
||||
bar += ((n.tick - seg_tick) + tpb - 1) / tpb;
|
||||
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
|
||||
seg_tick = n.tick;
|
||||
continue;
|
||||
}
|
||||
@@ -301,7 +301,7 @@ impl TempoMap {
|
||||
pub fn tick_at_bar_beat(&self, bb: BarBeat) -> i64 {
|
||||
let start = self.bar_start_tick(bb.bar);
|
||||
let m = self.meter_at_tick(start);
|
||||
start.saturating_add((bb.beat - 1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick)
|
||||
start.saturating_add(bb.beat.saturating_sub(1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick)
|
||||
}
|
||||
|
||||
pub fn bar_beat_at(&self, s: Samples, sr: SampleRate) -> BarBeat {
|
||||
@@ -323,6 +323,11 @@ impl TempoMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ceiling division for a non-negative `num` and positive `den`, without overflow.
|
||||
fn ceil_div(num: i64, den: i64) -> i64 {
|
||||
num / den + i64::from(num % den != 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -337,6 +342,16 @@ mod tests {
|
||||
assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_near_max_tick_does_not_overflow() {
|
||||
let mut m = TempoMap::default();
|
||||
m.set_meter(i64::MAX, 7, 8).unwrap();
|
||||
let bb = m.bar_beat_at_tick(i64::MAX);
|
||||
assert!(bb.bar > 1);
|
||||
assert!(m.bar_start_tick(bb.bar) > 0);
|
||||
assert!(m.bar_start_tick(i64::MAX) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_bar_saturates_instead_of_overflowing() {
|
||||
let m = TempoMap::default();
|
||||
@@ -344,6 +359,12 @@ mod tests {
|
||||
assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_beat_is_clamped_without_overflow() {
|
||||
let m = TempoMap::default();
|
||||
assert_eq!(m.tick_at_bar_beat(BarBeat { bar: 1, beat: i64::MIN, tick: 0 }), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_beat_at_min_tick_does_not_overflow() {
|
||||
let m = TempoMap::default();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
||||
//! standards/contributors.md). Nothing is read at run time.
|
||||
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
|
||||
//!
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
|
||||
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml
|
||||
//! entry is verified.
|
||||
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
||||
//! JSON says the entry was verified (`people_entry`).
|
||||
//!
|
||||
|
||||
@@ -105,6 +105,7 @@ impl SoundApp {
|
||||
quit_requested: false,
|
||||
arrange_request: false,
|
||||
touched: std::collections::HashSet::new(),
|
||||
touched_sends: std::collections::HashSet::new(),
|
||||
last_write_at: i64::MIN,
|
||||
editor_states: Vec::new(),
|
||||
editor_poll_due: true,
|
||||
@@ -159,7 +160,7 @@ impl SoundApp {
|
||||
if hidden {
|
||||
self.pointer_down = false;
|
||||
self.wants_keyboard = false;
|
||||
if matches!(self.gesture, Some(Gesture::Fader { .. })) {
|
||||
if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) {
|
||||
self.gesture = None;
|
||||
}
|
||||
}
|
||||
@@ -176,15 +177,14 @@ impl SoundApp {
|
||||
if ctx.current_pass_index() == 0 {
|
||||
bounce::step(self, ctx);
|
||||
import::step(self, ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::tick(self, ctx);
|
||||
}
|
||||
if !self.pointer_down {
|
||||
if matches!(self.gesture, Some(Gesture::Fader { .. })) {
|
||||
if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) {
|
||||
self.gesture = None;
|
||||
}
|
||||
// Keep a running automation pass merged until playback stops.
|
||||
if !self.wants_keyboard && self.touched.is_empty() {
|
||||
if !self.wants_keyboard && self.touched.is_empty() && self.touched_sends.is_empty() {
|
||||
self.engine.end_merge();
|
||||
}
|
||||
}
|
||||
@@ -340,7 +340,6 @@ impl SoundApp {
|
||||
score_editor::show(self, &ctx);
|
||||
palette::show(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::show(self, &ctx);
|
||||
// The sampler's drop rects are current now, so route any captured drop in this pass.
|
||||
self.route_dropped_files();
|
||||
|
||||
@@ -30,6 +30,9 @@ impl SoundApp {
|
||||
// Re-selecting a scrolling mode resumes the playhead follow.
|
||||
self.edit_layout.follow_hold = false;
|
||||
}
|
||||
if id == "view.zoom_toggle_cancel" && v["canceled"] == true {
|
||||
self.edit_layout.forget_zoom_back();
|
||||
}
|
||||
if id == "track.clear_clip_indicators" {
|
||||
self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false);
|
||||
}
|
||||
|
||||
@@ -204,7 +204,8 @@ impl SoundApp {
|
||||
}
|
||||
|
||||
/// While playing, record volume automation from fader positions for tracks in Write mode, and
|
||||
/// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Each point replaces the
|
||||
/// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Send levels follow the
|
||||
/// same rules, per send, from its slot or its floating send window. Each point replaces the
|
||||
/// automation since the previous one, and Stop writes a last point. A pass and whatever is
|
||||
/// dragged during it, its fader included, are one undo step.
|
||||
pub(crate) fn write_automation(&mut self) {
|
||||
@@ -214,7 +215,7 @@ impl SoundApp {
|
||||
let may_write =
|
||||
playing && self.engine.session().tracks.iter().any(|t| matches!(t.mixer.automation_mode, M::Write | M::Touch | M::Latch | M::TouchLatch));
|
||||
self.engine.hold_merge(may_write.then_some(PASS));
|
||||
if !playing && self.touched.is_empty() {
|
||||
if !playing && self.touched.is_empty() && self.touched_sends.is_empty() {
|
||||
self.last_write_at = i64::MIN;
|
||||
return;
|
||||
}
|
||||
@@ -230,6 +231,10 @@ impl SoundApp {
|
||||
Some(Gesture::Fader { track, .. }) => Some(*track),
|
||||
_ => None,
|
||||
};
|
||||
let send_fader = match &self.gesture {
|
||||
Some(Gesture::SendFader { track, slot }) => Some((*track, *slot)),
|
||||
_ => None,
|
||||
};
|
||||
let writes: Vec<(TrackId, f32)> = self
|
||||
.engine
|
||||
.session()
|
||||
@@ -247,9 +252,39 @@ impl SoundApp {
|
||||
write.then_some((t.id, t.mixer.volume_db))
|
||||
})
|
||||
.collect();
|
||||
for (t, v) in writes {
|
||||
let send_writes: Vec<(TrackId, usize, f32)> = self
|
||||
.engine
|
||||
.session()
|
||||
.tracks
|
||||
.iter()
|
||||
.flat_map(|t| {
|
||||
let m = t.mixer.automation_mode;
|
||||
t.mixer.sends.iter().enumerate().filter_map(move |(slot, s)| s.as_ref().map(|s| (t.id, m, slot, s.level_db)))
|
||||
})
|
||||
.filter(|&(t, m, slot, _)| {
|
||||
let touched_now = dragging && send_fader == Some((t, slot));
|
||||
match m {
|
||||
M::Write => true,
|
||||
M::Touch => touched_now,
|
||||
M::Latch | M::TouchLatch => touched_now || self.touched_sends.contains(&(t, slot)),
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.map(|(t, _, slot, db)| (t, slot, db))
|
||||
.collect();
|
||||
let points: Vec<(TrackId, String, f32)> = writes
|
||||
.iter()
|
||||
.map(|&(t, v)| (t, "volume".to_string(), v))
|
||||
.chain(send_writes.iter().map(|&(t, slot, v)| (t, format!("send_{}_level", (b'a' + slot as u8) as char), v)))
|
||||
.collect();
|
||||
for &(t, _) in &writes {
|
||||
self.touched.insert(t);
|
||||
let mut point = serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v});
|
||||
}
|
||||
for &(t, slot, _) in &send_writes {
|
||||
self.touched_sends.insert((t, slot));
|
||||
}
|
||||
for (t, param, v) in points {
|
||||
let mut point = serde_json::json!({"track": t.0, "param": param, "at": pos, "value": v});
|
||||
if from != i64::MIN {
|
||||
point["from"] = from.into();
|
||||
}
|
||||
@@ -257,6 +292,7 @@ impl SoundApp {
|
||||
}
|
||||
if !playing {
|
||||
self.touched.clear();
|
||||
self.touched_sends.clear();
|
||||
self.engine.end_merge();
|
||||
}
|
||||
}
|
||||
@@ -296,6 +332,7 @@ impl SoundApp {
|
||||
self.punch = None;
|
||||
self.gesture = None;
|
||||
self.touched.clear();
|
||||
self.touched_sends.clear();
|
||||
self.last_write_at = i64::MIN;
|
||||
self.edit_layout.follow_hold = false;
|
||||
self.meters.clear();
|
||||
@@ -306,6 +343,21 @@ impl SoundApp {
|
||||
self.play_from_selection();
|
||||
}
|
||||
}
|
||||
TransportRequest::PlayFrom(at) => {
|
||||
if self.is_playing() {
|
||||
// Playing on from `at` drops the end and loop of the earlier selection.
|
||||
if let Some(p) = &self.player {
|
||||
p.play(at, None, None);
|
||||
}
|
||||
if let Some(sim) = &mut self.sim {
|
||||
*sim = (at, None, None);
|
||||
}
|
||||
self.last_write_at = i64::MIN;
|
||||
self.engine.transport.position = at;
|
||||
} else {
|
||||
self.start_play(at, None, None);
|
||||
}
|
||||
}
|
||||
TransportRequest::Stop | TransportRequest::Pause => self.stop_play(),
|
||||
TransportRequest::TogglePlay => {
|
||||
if self.is_playing() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The About window's credits: every contributor (GitHub login always; display and real names only
|
||||
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
|
||||
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
|
||||
//! build.rs from contributors/contributors.json; nothing is
|
||||
//! read at run time.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
@@ -455,6 +455,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
audiosuite_window(app, ctx);
|
||||
crate::bounce::show(app, ctx);
|
||||
crate::import::show(app, ctx);
|
||||
// Whichever window shows, closing Strip Silence drops its rectangles and stops their scan.
|
||||
if !matches!(app.dialogs.open, Some(Dialog::StripSilence { .. } | Dialog::StripSilenceRename { .. })) {
|
||||
app.dialogs.strip_rectangles = StripRectangles::default();
|
||||
}
|
||||
let Some(mut d) = app.dialogs.open.take() else { return };
|
||||
let mut keep = true;
|
||||
let mut action: Option<(String, Value)> = None;
|
||||
|
||||
@@ -42,10 +42,7 @@ pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> Stri
|
||||
let values = match &app.dialogs.open {
|
||||
Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) => [*threshold, *min_ms, *pre_ms, *post_ms],
|
||||
Some(Dialog::StripSilenceRename { strip, .. }) => *strip,
|
||||
_ => {
|
||||
app.dialogs.strip_rectangles = StripRectangles::default();
|
||||
return Arc::default();
|
||||
}
|
||||
_ => return Arc::default(),
|
||||
};
|
||||
let key: StripKey = (app.engine.revision, values.map(f64::to_bits));
|
||||
let r = &mut app.dialogs.strip_rectangles;
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(super) fn fade_curve_drag_value(fade: soundcraft_model::Fade, anchor: (f32,
|
||||
|
||||
/// Runs before anything is painted, against the last frame's layout.
|
||||
pub(super) fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. })) {
|
||||
if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) {
|
||||
return;
|
||||
}
|
||||
let (s, tl) = (app.engine.session_arc(), rect(app.edit_layout.timeline));
|
||||
|
||||
@@ -511,7 +511,9 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
|
||||
}
|
||||
return;
|
||||
};
|
||||
let ids = json!([c.0]);
|
||||
// A clip of the edit selection acts on the whole selection (the commands default to it); any other clip acts alone.
|
||||
let in_selection = clip_in_selection(app.engine.session(), c);
|
||||
let target = if in_selection { json!({}) } else { json!({"clips": [c.0]}) };
|
||||
for (label, cmd) in [
|
||||
("Mute/Unmute", "edit.mute_clips"),
|
||||
("Edit Lock/Unlock", "clip.edit_lock"),
|
||||
@@ -522,7 +524,7 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
|
||||
("Send to Back", "clip.send_to_back"),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
let _ = app.run(cmd, json!({"clips": ids}));
|
||||
let _ = app.run(cmd, target.clone());
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
@@ -531,7 +533,28 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
|
||||
app.dialogs.open_rename_clip(c, &name);
|
||||
}
|
||||
if ui.button("Delete").clicked() {
|
||||
let _ = app.run("edit.select", json!({"clips": ids}));
|
||||
let _ = app.run("edit.clear", json!({}));
|
||||
let s = app.engine.session();
|
||||
let selected: Vec<u64> = s.edit.selected_clips.iter().map(|id| id.0).collect();
|
||||
let track: Vec<u64> = s.find_clip(c).map(|(t, _)| t.0).into_iter().collect();
|
||||
if !in_selection {
|
||||
let _ = app.run("edit.select", json!({"clips": [c.0]}));
|
||||
let _ = app.run("edit.clear", json!({}));
|
||||
} else if selected.is_empty() {
|
||||
let _ = app.run("edit.clear", json!({}));
|
||||
} else {
|
||||
// Selected clips go by id (an empty range), so clips between them that the span covers stay. The clicked
|
||||
// clip's track satisfies Clear's track check even when the track selection was emptied meanwhile.
|
||||
let _ = app.run("edit.clear", json!({"clips": selected, "tracks": track, "start": 0}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `c` is part of the edit selection: one of the selected clips or, when no clip is selected, a clip on a
|
||||
/// selected track that the selected range overlaps. An insertion point selects nothing.
|
||||
fn clip_in_selection(s: &Session, c: ClipId) -> bool {
|
||||
let ed = &s.edit;
|
||||
if !ed.selected_clips.is_empty() {
|
||||
return ed.selected_clips.contains(&c);
|
||||
}
|
||||
!ed.selection.is_empty() && s.find_clip(c).is_some_and(|(t, clip)| ed.selected_tracks.contains(&t) && clip.range().overlaps(&ed.selection))
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ impl EditLayout {
|
||||
pub fn scroll_by(&mut self, dy: f32, view_h: f32) {
|
||||
self.scroll_y = (self.scroll_y + dy).clamp(0.0, (self.content_h - view_h + 40.0).max(0.0));
|
||||
}
|
||||
|
||||
/// Clears the vertical scroll position Zoom Toggle would restore, so Cancel Zoom Toggle keeps the current scroll.
|
||||
pub(crate) fn forget_zoom_back(&mut self) {
|
||||
self.zoom_back = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rect([x0, y0, x1, y1]: [f32; 4]) -> Rect {
|
||||
|
||||
@@ -75,8 +75,13 @@ pub(super) fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Zoom Toggle sized to the track area on screen.
|
||||
/// Zoom Toggle sized to the track area on screen. With Alt held (an Alt-click on the button) it cancels Zoom Toggle
|
||||
/// instead and keeps the current view.
|
||||
pub fn zoom_toggle(app: &mut SoundApp) {
|
||||
if app.docking_context.as_ref().is_some_and(|c| c.input(|i| i.modifiers.alt)) {
|
||||
let _ = app.run("view.zoom_toggle_cancel", json!({}));
|
||||
return;
|
||||
}
|
||||
let [x0, y0, x1, y1] = app.edit_layout.timeline;
|
||||
let _ = app.run("view.zoom_toggle", json!({"width_px": x1 - x0, "height_px": y1 - y0}));
|
||||
}
|
||||
|
||||
@@ -246,6 +246,16 @@ fn hold_clears_when_playhead_returns_or_transport_stops() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel Zoom Toggle with Zoom Toggle already off keeps the scroll position a pending toggle-off restores.
|
||||
#[test]
|
||||
fn a_cancel_with_zoom_toggle_off_keeps_the_scroll_to_restore() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
|
||||
app.edit_layout.zoom_back = Some(120.0);
|
||||
let v = app.run("view.zoom_toggle_cancel", json!({})).unwrap();
|
||||
assert_eq!(v["canceled"], json!(false));
|
||||
assert_eq!(app.edit_layout.zoom_back, Some(120.0));
|
||||
}
|
||||
|
||||
fn harness(n: usize) -> Harness<'static, SoundApp> {
|
||||
let mut engine = soundcraft_engine::Engine::default();
|
||||
engine.execute("track.new", &json!({"count": n})).unwrap();
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
//! executing commands. It can be replaced without touching anything below it.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
/// SoundCraft as craft-update knows it, with its 128 px icon and accent colour: the update
|
||||
/// prompt shows both, and an AppImage's menu entry gets the icon.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub const UPDATE_APP: craft_update::app::App = craft_update::app::App {
|
||||
icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.soundcraft.png")),
|
||||
accent: Some([0x1f, 0xa5, 0xc9]),
|
||||
..craft_update::suite::SOUNDCRAFT
|
||||
};
|
||||
|
||||
pub mod appearance;
|
||||
pub mod bounce;
|
||||
pub mod control;
|
||||
@@ -235,6 +244,7 @@ pub enum Gesture {
|
||||
FadeCurve { clip: soundcraft_model::ClipId, fade_in: bool, curvature: f32, anchor: (f32, f32), height: f32 },
|
||||
Scrub { last: Samples },
|
||||
Fader { track: TrackId, start_db: f32 },
|
||||
SendFader { track: TrackId, slot: usize },
|
||||
ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 },
|
||||
Pencil { track: TrackId, points: Vec<(Samples, f32)> },
|
||||
Breakpoint { track: TrackId, param: soundcraft_model::AutoParam, at: Samples },
|
||||
@@ -384,6 +394,8 @@ pub struct SoundApp {
|
||||
pub arrange_request: bool,
|
||||
/// Tracks whose automation was touched during this pass (Touch/Latch writing).
|
||||
touched: std::collections::HashSet<TrackId>,
|
||||
/// Sends whose level was touched during this pass (Touch/Latch writing), as (track, slot).
|
||||
touched_sends: std::collections::HashSet<(TrackId, usize)>,
|
||||
/// The pass's last point; `i64::MIN` after a stop or a jump, so the next point erases nothing it skipped.
|
||||
last_write_at: Samples,
|
||||
/// Plugins changed in their editors whose state the session hasn't taken yet (see `editor_states_due`).
|
||||
|
||||
@@ -518,6 +518,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
if id == "ui.audio_device" {
|
||||
return Some(crate::windows::audio_device_command(app, p));
|
||||
}
|
||||
// Menu clicks never get here: Setup › Playback Engine / Hardware open the window
|
||||
// (`MENU_WINDOWS`). A programmatic call applies the open device instead.
|
||||
if matches!(id, "setup.playback_engine" | "setup.hardware") {
|
||||
return Some(crate::windows::setup_audio_command(app, id, p));
|
||||
}
|
||||
let toggle = |v: &mut bool| {
|
||||
*v = p.get("value").and_then(Value::as_bool).unwrap_or(!*v);
|
||||
json!({"value": *v})
|
||||
|
||||
@@ -46,6 +46,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
|
||||
// applying it to the already-updated value made trackpad adjustment accelerate.
|
||||
let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift));
|
||||
let db = dragged_send_db(s.level_db, dy, fine);
|
||||
app.gesture = Some(crate::Gesture::SendFader { track: id, slot });
|
||||
let _ =
|
||||
app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0));
|
||||
}
|
||||
@@ -120,6 +121,7 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
// Distinct from the strip's "Send A level for <track>" icon and from the slot's "Send level", so a
|
||||
// label query while the window is open still picks one node.
|
||||
if let Some(db) = fader(ui, fr, send.level_db, ui.scope_id().with("send_fader"), "Send level fader") {
|
||||
app.gesture = Some(crate::Gesture::SendFader { track: id, slot });
|
||||
let _ = app.engine.execute_merged(
|
||||
"mix.send_level",
|
||||
&json!({"track": id.0, "slot": slot, "db": db}),
|
||||
|
||||
@@ -517,6 +517,8 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
number_combo(ui, "pe-rate", "Sample rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz");
|
||||
ui.end_row();
|
||||
ui.label("Buffer size");
|
||||
// The combo lists standard power-of-two sizes. `setup.playback_engine` does not round
|
||||
// onto this list: it keeps any integer 16..=8192, the same as `ui.audio_device`.
|
||||
let buffers = out.map(DeviceInfo::buffer_sizes).unwrap_or_else(|| soundcraft_playback::devices::STANDARD_BUFFERS.to_vec());
|
||||
number_combo(ui, "pe-buffer", "Buffer size", &mut draft.buffer_size, &buffers, "samples");
|
||||
ui.end_row();
|
||||
@@ -623,6 +625,53 @@ pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Programmatic `setup.playback_engine` / `setup.hardware`. `device` is the output name and
|
||||
/// `buffer_size` is any integer 16..=8192 or null (not rounded to the window's power-of-two list);
|
||||
/// both go through [`audio_device_command`]. Delay compensation still goes through the engine
|
||||
/// command, and only after the device change has been accepted. The result is the open device:
|
||||
/// its name, the buffer actually in use (`device_buffer`, null for the device default), its sample
|
||||
/// rate, and delay compensation. `applied` is true only when an audio device is open.
|
||||
pub fn setup_audio_command(app: &mut SoundApp, id: &str, p: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
use serde_json::Value;
|
||||
let mut audio = serde_json::Map::new();
|
||||
if let Some(device) = p.get("device") {
|
||||
if !device.is_null() && !device.is_string() {
|
||||
return Err("`device` must be a string or null".into());
|
||||
}
|
||||
audio.insert("output".to_string(), device.clone());
|
||||
}
|
||||
if let Some(buffer) = p.get("buffer_size") {
|
||||
audio.insert("buffer_size".to_string(), buffer.clone());
|
||||
}
|
||||
if !audio.is_empty() {
|
||||
audio_device_command(app, &Value::Object(audio))?;
|
||||
}
|
||||
if id == "setup.playback_engine"
|
||||
&& let Some(on) = p.get("delay_compensation").and_then(Value::as_bool)
|
||||
{
|
||||
app.engine.execute("setup.playback_engine", &json!({"delay_compensation": on})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let applied = app.player.as_ref().is_some_and(|player| !player.silent);
|
||||
let (device, buffer_size, sample_rate) = match app.player.as_ref() {
|
||||
Some(player) => {
|
||||
let buffer = match player.device_buffer {
|
||||
Some(n) => json!(n),
|
||||
None => Value::Null,
|
||||
};
|
||||
(json!(player.device_name), buffer, json!(player.device_rate))
|
||||
}
|
||||
None => (Value::Null, Value::Null, json!(app.engine.session().sample_rate.hz())),
|
||||
};
|
||||
Ok(json!({
|
||||
"applied": applied,
|
||||
"device": device,
|
||||
"buffer_size": buffer_size,
|
||||
"sample_rate": sample_rate,
|
||||
"bit_depth": app.engine.session().bit_depth,
|
||||
"delay_compensation": app.engine.session().edit.delay_compensation,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Setup › I/O: busses and output paths.
|
||||
pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let mut open = app.ui.show_io_setup;
|
||||
|
||||
@@ -62,6 +62,36 @@ fn new_session_cancels_play_queued_before_the_next_frame() {
|
||||
assert_eq!(app.engine.transport.position, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_from_starts_at_from_regardless_of_the_selection() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.run("edit.select", json!({"tracks": ["Kick"], "start": 7_632_000, "end": 7_680_000})).unwrap();
|
||||
app.run("transport.play", json!({"from": 4_560_000})).unwrap();
|
||||
app.handle_transport(0.25);
|
||||
assert!(app.is_playing());
|
||||
assert_eq!(app.engine.transport.position, 4_560_000 + 12_000);
|
||||
|
||||
app.run("transport.play", json!({"from": 96_000})).unwrap();
|
||||
app.handle_transport(0.25);
|
||||
assert!(app.is_playing());
|
||||
assert_eq!(app.engine.transport.position, 96_000 + 12_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_from_while_looping_drops_the_selection_loop() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.run("options.loop_playback", json!({"value": true})).unwrap();
|
||||
app.run("edit.select", json!({"tracks": ["Kick"], "start": 96_000, "end": 144_000})).unwrap();
|
||||
app.run("transport.play", json!({})).unwrap();
|
||||
app.handle_transport(0.25);
|
||||
assert!(app.is_playing());
|
||||
app.run("transport.play", json!({"from": 480_000})).unwrap();
|
||||
app.handle_transport(0.25);
|
||||
app.handle_transport(0.25);
|
||||
assert!(app.is_playing());
|
||||
assert_eq!(app.engine.transport.position, 480_000 + 24_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_with_nothing_armed_says_what_to_do() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
@@ -244,6 +274,67 @@ fn a_pass_and_the_fader_ride_driving_it_are_one_undo_step() {
|
||||
assert_eq!(app.engine.undo_history().len(), steps + 1);
|
||||
}
|
||||
|
||||
/// #1146: a send's level writes its own automation lane, by the track's mode like its fader: Touch
|
||||
/// while the send is dragged, Latch from the first drag until stop, Write throughout. The track's
|
||||
/// volume lane is left alone.
|
||||
#[test]
|
||||
fn a_dragged_send_level_writes_send_automation() {
|
||||
use soundcraft_model::AutoParam;
|
||||
let points = |app: &SoundApp, param: &AutoParam| app.engine.session().track_by_name("Lead").unwrap().lane(param).map_or(0, |l| l.points.len());
|
||||
let level = |app: &SoundApp| app.engine.session().track_by_name("Lead").unwrap().lane(&AutoParam::SendLevel(0)).map(|l| l.points.clone());
|
||||
let pass = |app: &mut SoundApp| {
|
||||
app.handle_transport(0.1);
|
||||
app.write_automation();
|
||||
};
|
||||
for mode in ["touch", "latch", "write"] {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let lead = app.engine.session().track_by_name("Lead").unwrap().id;
|
||||
assert!(app.engine.session().track(lead).unwrap().mixer.sends[0].is_some(), "the demo's Lead sends to the reverb");
|
||||
let volume = points(&app, &AutoParam::Volume);
|
||||
app.run("mix.automation_mode", json!({"tracks": ["Lead"], "mode": mode})).unwrap();
|
||||
app.run("transport.play", json!({})).unwrap();
|
||||
for _ in 0..3 {
|
||||
pass(&mut app);
|
||||
}
|
||||
let before_drag = points(&app, &AutoParam::SendLevel(0));
|
||||
if mode == "write" {
|
||||
assert!(before_drag > 0, "Write writes the send without a drag");
|
||||
} else {
|
||||
assert_eq!(before_drag, 0, "{mode}: nothing written before the send is touched");
|
||||
}
|
||||
// What the send's slot and its floating window do while the level is dragged.
|
||||
app.pointer_down = true;
|
||||
app.gesture = Some(super::Gesture::SendFader { track: lead, slot: 0 });
|
||||
for frame in 0..4 {
|
||||
pass(&mut app);
|
||||
let db = -12.0 + frame as f32;
|
||||
app.engine.execute_merged("mix.send_level", &json!({"track": lead.0, "slot": 0, "db": db}), &format!("send:{}:0", lead.0)).unwrap();
|
||||
}
|
||||
let dragged = points(&app, &AutoParam::SendLevel(0));
|
||||
assert!(dragged >= before_drag + 3, "{mode}: the drag is written ({before_drag} -> {dragged})");
|
||||
assert!(level(&app).unwrap().iter().any(|p| (p.value - (-10.0)).abs() < 1e-6), "{mode}: the points follow the send level");
|
||||
// Released.
|
||||
app.pointer_down = false;
|
||||
app.gesture = None;
|
||||
for _ in 0..3 {
|
||||
pass(&mut app);
|
||||
}
|
||||
let released = points(&app, &AutoParam::SendLevel(0));
|
||||
if mode == "touch" {
|
||||
assert_eq!(released, dragged, "Touch stops writing when the send is let go");
|
||||
} else {
|
||||
assert!(released > dragged, "{mode}: keeps writing after the release");
|
||||
}
|
||||
app.run("transport.stop", json!({})).unwrap();
|
||||
app.handle_transport(0.0);
|
||||
app.write_automation();
|
||||
if mode != "write" {
|
||||
assert_eq!(points(&app, &AutoParam::Volume), volume, "{mode}: the track's volume lane is left alone");
|
||||
}
|
||||
assert!(app.touched_sends.is_empty(), "{mode}: stop ends the pass");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_states_read_during_a_drag_keep_it_one_undo_step() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
@@ -1515,9 +1606,15 @@ fn editor_gesture_polling_survives_discarded_and_hidden_host_iterations() {
|
||||
}
|
||||
|
||||
/// The engine's revision, undo history and journal are as they were.
|
||||
fn assert_history_unchanged(app: &SoundApp, revision: u64, undo: usize, history: &[String], journal: &[(String, serde_json::Value)]) {
|
||||
fn assert_history_unchanged(
|
||||
app: &SoundApp,
|
||||
revision: u64,
|
||||
undo: usize,
|
||||
history: &[String],
|
||||
journal: &std::collections::VecDeque<(String, serde_json::Value)>,
|
||||
) {
|
||||
assert_eq!(app.engine.revision, revision);
|
||||
assert_eq!(app.engine.undo_history().len(), undo);
|
||||
assert_eq!(app.engine.undo_history(), history);
|
||||
assert_eq!(app.engine.journal, journal);
|
||||
assert_eq!(&app.engine.journal, journal);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use crate::SoundApp;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use craft_update::dialog::{CheckResultDialog, CheckResultResponse};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use craft_update::updater::{Action, Status, Updater};
|
||||
|
||||
@@ -13,21 +15,24 @@ pub(crate) struct Runtime {
|
||||
startup_check: bool,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
check_requested: bool,
|
||||
pub(crate) manual_window_open: bool,
|
||||
pub(crate) status_message: Option<String>,
|
||||
/// The check-result dialog (native) or the web build's notice is showing.
|
||||
manual_window_open: bool,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
result_dialog: CheckResultDialog,
|
||||
}
|
||||
|
||||
impl Default for Runtime {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updater: Updater::new(&craft_update::suite::SOUNDCRAFT, env!("CARGO_PKG_VERSION")),
|
||||
updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
startup_check: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
check_requested: false,
|
||||
manual_window_open: false,
|
||||
status_message: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
result_dialog: CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +53,6 @@ pub(crate) fn check(app: &mut SoundApp) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
app.updates_rt.manual_window_open = true;
|
||||
app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +62,7 @@ pub(crate) fn tick(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let rt = &mut app.updates_rt;
|
||||
if std::mem::take(&mut rt.check_requested) {
|
||||
rt.updater.check_now(ctx);
|
||||
rt.manual_window_open = true;
|
||||
} else if rt.startup_check {
|
||||
rt.updater.check_at_start(ctx);
|
||||
}
|
||||
@@ -77,46 +82,36 @@ pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
Action::None => {}
|
||||
}
|
||||
// The window answers Help ▸ Check for Updates, so it follows the updater's status.
|
||||
// Help ▸ Check for Updates shows its outcome in the shared result dialog until the user closes it.
|
||||
let rt = &mut app.updates_rt;
|
||||
(rt.manual_window_open, rt.status_message) = match rt.updater.status() {
|
||||
Status::Idle => (false, None),
|
||||
Status::Checking => (true, None),
|
||||
Status::UpToDate => (true, Some(format!("SoundCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))),
|
||||
Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))),
|
||||
};
|
||||
if rt.manual_window_open {
|
||||
match rt.result_dialog.show(ctx, rt.updater.status()) {
|
||||
CheckResultResponse::Close => rt.manual_window_open = false,
|
||||
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
|
||||
CheckResultResponse::None => {}
|
||||
}
|
||||
// A found update goes to the startup prompt, which takes over from the dialog.
|
||||
if matches!(rt.updater.status(), Status::Idle) {
|
||||
rt.manual_window_open = false;
|
||||
}
|
||||
}
|
||||
// Closing the dialog dismisses the outcome it showed.
|
||||
if !rt.manual_window_open {
|
||||
rt.updater.dismiss_status();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if app.updates_rt.manual_window_open {
|
||||
let mut open = true;
|
||||
let mut close = false;
|
||||
egui::Window::new("Check for Updates")
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
if let Some(msg) = &app.updates_rt.status_message {
|
||||
ui.label(msg);
|
||||
} else {
|
||||
ui.label("Checking for updates…");
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
ui.label("Update check is not supported in the web browser.");
|
||||
});
|
||||
if !open || close {
|
||||
app.updates_rt.manual_window_open = false;
|
||||
app.updates_rt.status_message = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Closing the window dismisses the outcome it showed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !app.updates_rt.manual_window_open {
|
||||
app.updates_rt.updater.dismiss_status();
|
||||
app.updates_rt.manual_window_open = open;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,3 +25,79 @@ fn the_audio_device_choice_is_kept_only_when_it_is_applied() {
|
||||
assert_eq!(r["config"]["buffer_size"], 256);
|
||||
assert!(r["device"].is_object(), "{r}");
|
||||
}
|
||||
|
||||
fn new_app() -> SoundApp {
|
||||
SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_engine_refuses_an_invalid_or_untimely_device_choice_without_side_effects() {
|
||||
let mut app = new_app();
|
||||
let before = app.ui.audio.clone();
|
||||
let bit_depth = app.engine.session().bit_depth;
|
||||
assert!(!app.ui.show_playback_engine);
|
||||
|
||||
let r = app.run("setup.hardware", json!({})).unwrap();
|
||||
assert_eq!(r["applied"], false, "without a player there is no open device");
|
||||
assert_eq!(r["bit_depth"], json!(bit_depth));
|
||||
|
||||
assert!(app.engine.session().edit.delay_compensation, "delay compensation starts on");
|
||||
let err = app.run("setup.playback_engine", json!({"buffer_size": 8, "delay_compensation": false})).unwrap_err();
|
||||
assert!(err.contains("16..=8192"), "{err}");
|
||||
assert_eq!(app.ui.audio, before, "an invalid buffer changes nothing");
|
||||
assert!(app.engine.session().edit.delay_compensation, "an invalid buffer does not apply delay compensation");
|
||||
|
||||
app.engine.transport.recording = true;
|
||||
let err = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap_err();
|
||||
assert!(err.contains("stop recording"), "{err}");
|
||||
assert_eq!(app.ui.audio, before);
|
||||
assert!(app.engine.session().edit.delay_compensation, "a refused device change does not apply delay compensation");
|
||||
app.engine.transport.recording = false;
|
||||
|
||||
let err = app.run("setup.playback_engine", json!({"device": 42, "delay_compensation": false})).unwrap_err();
|
||||
assert!(err.contains("`device` must be a string or null"), "{err}");
|
||||
assert_eq!(app.ui.audio, before, "an invalid device changes nothing");
|
||||
assert!(app.engine.session().edit.delay_compensation, "an invalid device does not apply delay compensation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_engine_applies_the_device_choice_and_reports_the_open_device() {
|
||||
let mut app = new_app();
|
||||
let bit_depth = app.engine.session().bit_depth;
|
||||
let r = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap();
|
||||
assert_eq!(app.ui.audio.buffer_size, Some(256));
|
||||
assert!(!app.engine.session().edit.delay_compensation);
|
||||
assert!(!app.ui.show_playback_engine, "a programmatic call does not open the window");
|
||||
assert_eq!(r["delay_compensation"], false);
|
||||
let player = app.player.as_ref().unwrap();
|
||||
assert_eq!(r["applied"], !player.silent);
|
||||
assert_eq!(r["device"], json!(player.device_name));
|
||||
assert_eq!(r["buffer_size"], json!(player.device_buffer));
|
||||
assert_eq!(r["sample_rate"], player.device_rate);
|
||||
assert_eq!(r["bit_depth"], json!(bit_depth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_engine_applies_the_same_device_choice_as_the_audio_command() {
|
||||
let mut app = new_app();
|
||||
app.run("setup.playback_engine", json!({"delay_compensation": false})).unwrap();
|
||||
|
||||
// 300 is in range and is not rounded onto the window's power-of-two list.
|
||||
let mut via_window_command = new_app();
|
||||
via_window_command.run("ui.audio_device", json!({"buffer_size": 300, "output": "Elsewhere"})).unwrap();
|
||||
app.run("setup.hardware", json!({"buffer_size": 300, "device": "Elsewhere"})).unwrap();
|
||||
assert_eq!(app.ui.audio.buffer_size, Some(300));
|
||||
assert_eq!(app.ui.audio.buffer_size, via_window_command.ui.audio.buffer_size);
|
||||
assert_eq!(app.ui.audio.output, via_window_command.ui.audio.output);
|
||||
assert!(!app.ui.show_playback_engine);
|
||||
|
||||
app.run("setup.playback_engine", json!({"buffer_size": null, "device": null})).unwrap();
|
||||
assert_eq!(app.ui.audio.buffer_size, None);
|
||||
assert_eq!(app.ui.audio.output, None);
|
||||
assert!(!app.engine.session().edit.delay_compensation, "clearing the device leaves delay compensation");
|
||||
|
||||
let cfg = app.ui.audio.clone();
|
||||
soundcraft_ui_egui::menus::invoke_menu(&mut app, "setup.playback_engine", "Setup > Playback Engine...");
|
||||
assert!(app.ui.show_playback_engine, "the menu still opens the Playback Engine window");
|
||||
assert_eq!(app.ui.audio, cfg, "opening the window does not change the device");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Regression: the clip context menu must stay open while the pointer moves onto it, so its
|
||||
//! Delete item can be used. It used to vanish once the pointer left the lane, because the lane
|
||||
//! returned early (no hover position) before the menu was drawn.
|
||||
//! returned early (no hover position) before the menu was drawn. A right-click on a clip of the
|
||||
//! edit selection acts on the whole selection (#1191).
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
@@ -24,15 +25,16 @@ fn press(h: &mut Harness<'_, SoundApp>, x: f32, y: f32, button: PointerButton) {
|
||||
s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE });
|
||||
}
|
||||
|
||||
fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize {
|
||||
fn clip_count(h: &Harness<'_, SoundApp>, track: &str) -> usize {
|
||||
let s = h.state().engine.session();
|
||||
s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len())
|
||||
s.tracks.iter().find(|t| t.name == track).map_or(0, |t| t.clips().len())
|
||||
}
|
||||
|
||||
/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete
|
||||
/// removes one Kick clip.
|
||||
#[test]
|
||||
fn clip_menu_stays_open_while_moving_onto_delete() {
|
||||
fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize {
|
||||
clip_count(h, "Kick")
|
||||
}
|
||||
|
||||
fn demo() -> Harness<'static, SoundApp> {
|
||||
let engine = soundcraft_engine::demo::demo_engine();
|
||||
let app = SoundApp::new(engine, None, Services::default());
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
|
||||
@@ -46,26 +48,123 @@ fn clip_menu_stays_open_while_moving_onto_delete() {
|
||||
for _ in 0..6 {
|
||||
step(&mut h);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Selects the whole session on `tracks`, as a drag across their clips does.
|
||||
fn select_all_of(h: &mut Harness<'_, SoundApp>, tracks: &[&str]) {
|
||||
h.state_mut().engine.execute("edit.select", &serde_json::json!({"tracks": tracks, "start": 0, "end": 48_000 * 600})).unwrap();
|
||||
}
|
||||
|
||||
/// Right-clicks the Kick clip and clicks `item` in its menu, moving onto the item first as a real pointer would.
|
||||
fn kick_menu(h: &mut Harness<'_, SoundApp>, item: &str) {
|
||||
press(h, 880.0, 240.0, PointerButton::Secondary);
|
||||
for _ in 0..10 {
|
||||
step(h);
|
||||
}
|
||||
// Ask the newly laid-out menu where the item is instead of pinning coordinates that change with egui's item metrics.
|
||||
let at = h.get_by_label(item).rect().center();
|
||||
h.state_mut().synthetic.push(Event::PointerMoved(at));
|
||||
for _ in 0..10 {
|
||||
step(h);
|
||||
}
|
||||
press(h, at.x, at.y, PointerButton::Primary);
|
||||
for _ in 0..15 {
|
||||
step(h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete
|
||||
/// removes one Kick clip.
|
||||
#[test]
|
||||
fn clip_menu_stays_open_while_moving_onto_delete() {
|
||||
let mut h = demo();
|
||||
let before = kick_clip_count(&h);
|
||||
assert!(before > 0, "the demo session should have a recorded Kick clip");
|
||||
|
||||
// Right-click the Kick clip, then move the pointer into the menu and down to Delete.
|
||||
press(&mut h, 880.0, 240.0, PointerButton::Secondary);
|
||||
for _ in 0..10 {
|
||||
step(&mut h);
|
||||
}
|
||||
// Move (no click) onto the menu's Delete item, as a real pointer would, so the lane loses
|
||||
// its hover. Ask the newly laid-out menu where the item is instead of pinning coordinates
|
||||
// that change with egui's item metrics.
|
||||
let delete = h.get_by_label("Delete").rect().center();
|
||||
h.state_mut().synthetic.push(Event::PointerMoved(delete));
|
||||
for _ in 0..10 {
|
||||
step(&mut h);
|
||||
}
|
||||
press(&mut h, delete.x, delete.y, PointerButton::Primary);
|
||||
for _ in 0..15 {
|
||||
step(&mut h);
|
||||
}
|
||||
kick_menu(&mut h, "Delete");
|
||||
|
||||
assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip");
|
||||
}
|
||||
|
||||
/// Delete on a clip of a drag selection clears the whole selection, as Edit > Clear does, in one undo step (#1191).
|
||||
#[test]
|
||||
fn delete_on_a_selected_clip_clears_the_whole_selection() {
|
||||
let mut h = demo();
|
||||
let (kick, snare) = (clip_count(&h, "Kick"), clip_count(&h, "Snare"));
|
||||
assert!(snare > 1, "the demo session should have several Snare clips");
|
||||
select_all_of(&mut h, &["Kick", "Snare"]);
|
||||
|
||||
kick_menu(&mut h, "Delete");
|
||||
|
||||
assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (0, 0), "every selected clip should be cleared");
|
||||
h.state_mut().engine.execute("edit.undo", &serde_json::json!({})).unwrap();
|
||||
assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (kick, snare), "one undo should bring them all back");
|
||||
}
|
||||
|
||||
/// A clip outside the selection is still deleted on its own, and the selection's clips stay.
|
||||
#[test]
|
||||
fn delete_on_a_clip_outside_the_selection_removes_only_that_clip() {
|
||||
let mut h = demo();
|
||||
let (kick, snare) = (clip_count(&h, "Kick"), clip_count(&h, "Snare"));
|
||||
select_all_of(&mut h, &["Snare"]);
|
||||
|
||||
kick_menu(&mut h, "Delete");
|
||||
|
||||
assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (kick - 1, snare));
|
||||
}
|
||||
|
||||
/// With clips selected one by one (Grabber shift-click), Delete removes exactly those clips, not the other clips
|
||||
/// their span covers.
|
||||
#[test]
|
||||
fn delete_on_selected_clips_keeps_the_unselected_clips_between_them() {
|
||||
let mut h = demo();
|
||||
let ids = |h: &Harness<'_, SoundApp>, track: &str| -> Vec<u64> {
|
||||
let s = h.state().engine.session();
|
||||
s.tracks.iter().filter(|t| t.name == track).flat_map(|t| t.clips()).map(|c| c.id.0).collect()
|
||||
};
|
||||
let (kick, snare) = (ids(&h, "Kick"), ids(&h, "Snare"));
|
||||
let last_snare = *snare.last().unwrap();
|
||||
let clips: Vec<u64> = kick.iter().copied().chain([last_snare]).collect();
|
||||
h.state_mut().engine.execute("edit.select", &serde_json::json!({ "clips": clips })).unwrap();
|
||||
|
||||
kick_menu(&mut h, "Delete");
|
||||
|
||||
assert!(ids(&h, "Kick").is_empty(), "the selected Kick clips should be removed");
|
||||
let left: Vec<u64> = snare.iter().copied().filter(|id| *id != last_snare).collect();
|
||||
assert_eq!(ids(&h, "Snare"), left, "only the selected Snare clip should be removed");
|
||||
}
|
||||
|
||||
/// Selected clips are deleted even when the track selection was emptied after selecting them (Edit > Selection >
|
||||
/// Remove Edit from Top keeps the clip selection).
|
||||
#[test]
|
||||
fn delete_on_selected_clips_works_without_a_track_selection() {
|
||||
let mut h = demo();
|
||||
let kick = clip_count(&h, "Kick");
|
||||
let clips: Vec<u64> = {
|
||||
let s = h.state().engine.session();
|
||||
s.tracks.iter().filter(|t| t.name == "Kick").flat_map(|t| t.clips()).map(|c| c.id.0).collect()
|
||||
};
|
||||
let e = &mut h.state_mut().engine;
|
||||
e.execute("edit.select", &serde_json::json!({ "clips": clips })).unwrap();
|
||||
e.execute("edit.remove_selection_top", &serde_json::json!({})).unwrap();
|
||||
assert!(e.session().edit.selected_tracks.is_empty() && !e.session().edit.selected_clips.is_empty());
|
||||
|
||||
kick_menu(&mut h, "Delete");
|
||||
|
||||
assert_eq!(clip_count(&h, "Kick"), kick - clips.len(), "the selected Kick clips should be removed");
|
||||
}
|
||||
|
||||
/// The menu's other entries act on the whole selection too.
|
||||
#[test]
|
||||
fn mute_on_a_selected_clip_mutes_every_selected_clip() {
|
||||
let mut h = demo();
|
||||
select_all_of(&mut h, &["Kick", "Snare"]);
|
||||
|
||||
kick_menu(&mut h, "Mute/Unmute");
|
||||
|
||||
let s = h.state().engine.session();
|
||||
let clips: Vec<_> = s.tracks.iter().filter(|t| t.name == "Kick" || t.name == "Snare").flat_map(|t| t.clips()).collect();
|
||||
assert!(clips.len() > 2);
|
||||
assert!(clips.iter().all(|c| c.muted), "every selected clip should be muted");
|
||||
}
|
||||
|
||||
@@ -25,8 +25,6 @@ const KNOWN_UNREACHED: &[&str] = &[
|
||||
"mix.solo_safe",
|
||||
"options.post_roll",
|
||||
"options.pre_roll",
|
||||
"setup.hardware", // menu opens the Playback Engine window, which doesn't run it
|
||||
"setup.playback_engine", // same
|
||||
"setup.reset_edit_state",
|
||||
"track.load_preset",
|
||||
"track.pin",
|
||||
@@ -93,6 +91,13 @@ fn every_command_has_a_ui_path() {
|
||||
assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_engine_menu_opens_the_window_instead_of_running_the_command() {
|
||||
let extra = menus::ui_aliases();
|
||||
assert_eq!(catalog::implemented_by("Setup > Playback Engine...", &extra).as_deref(), Some("window.playback_engine"));
|
||||
assert_eq!(catalog::implemented_by("Setup > Hardware...", &extra).as_deref(), Some("window.playback_engine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_midi_clip_is_in_the_clip_menu() {
|
||||
let extra = menus::ui_aliases();
|
||||
|
||||
@@ -109,3 +109,18 @@ fn the_rectangles_stay_while_rename_is_open_and_go_when_the_window_closes() {
|
||||
assert_eq!(rectangles(&h), 0);
|
||||
assert_eq!(clips(&h, track), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_window_while_the_mix_window_shows_stops_the_scan() {
|
||||
let (mut h, _) = strip_silence_dialog();
|
||||
if let Some(Dialog::StripSilence { threshold, .. }) = &mut h.state_mut().dialogs.open {
|
||||
*threshold = -30.0;
|
||||
}
|
||||
h.run_steps(1);
|
||||
assert!(h.state().dialogs.strip_scan_running(), "a scan for the new threshold started");
|
||||
h.state_mut().run("window.mix", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.state_mut().dialogs.open = None;
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().dialogs.strip_scan_running(), "the scan still runs after the window closed");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user