mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-06 18:29:56 -06:00
Compare commits
278
Commits
v0.14.0
...
house-rules
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf178835f | ||
|
|
e66a5e7c67 | ||
|
|
87f46cf23b | ||
|
|
54cb247e12 | ||
|
|
73eeb692fe | ||
|
|
bd75fe691b | ||
|
|
6c8a9dd300 | ||
|
|
d393730b02 | ||
|
|
4240ffda42 | ||
|
|
c046c3bb1b | ||
|
|
e6cbf76148 | ||
|
|
1db9563379 | ||
|
|
341a112757 | ||
|
|
594d0ade4f | ||
|
|
92e6973640 | ||
|
|
2b03df3ad4 | ||
|
|
5fabf28c31 | ||
|
|
fa34e0c182 | ||
|
|
ccebb8ff93 | ||
|
|
b7d78c7e9e | ||
|
|
b26ab2b25c | ||
|
|
76f58fcb81 | ||
|
|
6a4c055094 | ||
|
|
d5564bce05 | ||
|
|
87ad11cccc | ||
|
|
b11ffe73ae | ||
|
|
f91b4173b6 | ||
|
|
98c3a307c3 | ||
|
|
6492b5f27e | ||
|
|
9c202d6f17 | ||
|
|
44b864ffaf | ||
|
|
035de82c84 | ||
|
|
181f329ec8 | ||
|
|
5ba9f9d7b8 | ||
|
|
c15682a088 | ||
|
|
0d253b6d8c | ||
|
|
c0f2f7dc87 | ||
|
|
f99407fb1f | ||
|
|
636454e639 | ||
|
|
7546ab992b | ||
|
|
f86b077cdf | ||
|
|
aa250cc1d7 | ||
|
|
dbcbcbac00 | ||
|
|
351b9d3a55 | ||
|
|
da02c19fcc | ||
|
|
64540f109b | ||
|
|
0af1ad909e | ||
|
|
7587130d59 | ||
|
|
e4f0599a2a | ||
|
|
17d3f00700 | ||
|
|
f46ea7d70c | ||
|
|
1403bbc4d3 | ||
|
|
b0793ca875 | ||
|
|
1c350101bc | ||
|
|
36d42e0136 | ||
|
|
c692648200 | ||
|
|
bf274560ae | ||
|
|
b35dd0f9eb | ||
|
|
087978a4a0 | ||
|
|
9b5554e312 | ||
|
|
04dab602c7 | ||
|
|
e5d3174929 | ||
|
|
27329587c6 | ||
|
|
1dbc185e01 | ||
|
|
576ff36f61 | ||
|
|
4b06407dae | ||
|
|
1a95692f50 | ||
|
|
7dfa7e470f | ||
|
|
8f7eb4d777 | ||
|
|
ffc035ba29 | ||
|
|
8444edf075 | ||
|
|
6c3a0dfc50 | ||
|
|
d557d18118 | ||
|
|
98cda6ecaf | ||
|
|
920ec2c3f3 | ||
|
|
5e8898976e | ||
|
|
c426b889bb | ||
|
|
e1f10ccdc8 | ||
|
|
627bbbf9cc | ||
|
|
84baa5debc | ||
|
|
468bec487c | ||
|
|
db51dd1739 | ||
|
|
d588e74667 | ||
|
|
71f899baac | ||
|
|
ccbeec6680 | ||
|
|
bdb73895a5 | ||
|
|
1a4a88fb0d | ||
|
|
9e902b48fe | ||
|
|
265e526f6a | ||
|
|
d2298ff974 | ||
|
|
cf40296888 | ||
|
|
7169dc492e | ||
|
|
381832447c | ||
|
|
b32065a109 | ||
|
|
85ffa1f53e | ||
|
|
97e2603683 | ||
|
|
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 | ||
|
|
73e3478ae3 | ||
|
|
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 | ||
|
|
4740e8d4c4 | ||
|
|
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 | ||
|
|
fd9c673694 | ||
|
|
1a20190149 | ||
|
|
1ffc164082 | ||
|
|
c14dc61473 | ||
|
|
ceb3404c31 | ||
|
|
7c029c40ce | ||
|
|
ed0aed5ccb | ||
|
|
1aab8e449d | ||
|
|
c7a7cfa09b | ||
|
|
79c45a154d | ||
|
|
d7c194d728 | ||
|
|
9adcc1f389 | ||
|
|
f92150c64e | ||
|
|
795f1cb6ef | ||
|
|
e631966cf7 | ||
|
|
45498c8268 | ||
|
|
afa1532aba | ||
|
|
0d0d0abc27 | ||
|
|
9dc26fa1ca | ||
|
|
75e5b90a86 | ||
|
|
7a9b3e38e1 | ||
|
|
9a99c82d2c | ||
|
|
0c7212854a | ||
|
|
84e591e1ca | ||
|
|
1f8b267722 | ||
|
|
ee55acbc93 | ||
|
|
e294aa48c0 | ||
|
|
021caab499 | ||
|
|
66f305b747 | ||
|
|
fa77e03e16 | ||
|
|
ccd3fe7ca2 | ||
|
|
b9accf92a8 | ||
|
|
d33cac774a | ||
|
|
9f049ac7d0 | ||
|
|
7509b64c5c | ||
|
|
dab64379cb | ||
|
|
95f25979eb | ||
|
|
929a7c5307 | ||
|
|
ee63b09cbe | ||
|
|
4f771268b0 | ||
|
|
8385ed94c6 | ||
|
|
8f23afe462 | ||
|
|
6d86a52310 | ||
|
|
9d5f5784d5 | ||
|
|
4071975aa4 | ||
|
|
2e057309b9 | ||
|
|
9e9177f1af | ||
|
|
3ea1f34a1d | ||
|
|
6e8c039097 | ||
|
|
5d7197a2d0 | ||
|
|
93ac4f2913 | ||
|
|
352d90a24a | ||
|
|
b48a939d64 | ||
|
|
7849e8cf51 |
@@ -0,0 +1,28 @@
|
||||
name: House rules
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: house-rules-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
house-rules:
|
||||
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
|
||||
with:
|
||||
rust: true
|
||||
@@ -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:
|
||||
@@ -40,3 +41,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,62 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# SoundCraft — instructions for agents
|
||||
|
||||
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.
|
||||
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
|
||||
Read `ROADMAP.md` for stage, status and the parity numbers, and 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. `plan/` is an optional gitignored notebook (`plan/protools/` holds an observed menu tree and screenshots, never committed). Do not ask for it and do not stop when it is absent.
|
||||
|
||||
## ⚠️ Assets: the absolute rule
|
||||
**No Avid, Pro Tools, Adobe or other proprietary iconography, images, sounds, presets or artwork — ever.** This is not negotiable.
|
||||
@@ -22,7 +71,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).
|
||||
@@ -32,7 +81,6 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
|
||||
- The audio callback never blocks, allocates in steady state, or panics.
|
||||
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
|
||||
|
||||
## Architecture (layers enforced by `cargo xtask layers`)
|
||||
@@ -67,7 +115,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 +129,15 @@ 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.
|
||||
|
||||
New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
|
||||
|
||||
+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 |
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
Read `AGENTS.md`: it is the entry point for agents in this repo.
|
||||
|
||||
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
|
||||
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
|
||||
before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
@AGENTS.md
|
||||
|
||||
Generated
+111
-31
@@ -277,6 +277,12 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -1058,17 +1064,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 +1097,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 +1105,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 +1126,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",
|
||||
@@ -1415,6 +1422,19 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools 0.15.0",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1468,6 +1488,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -4005,6 +4045,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -4279,7 +4330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -4303,7 +4354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4313,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4325,7 +4376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
@@ -4338,7 +4389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4351,11 +4402,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"log",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4368,7 +4420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4379,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4402,7 +4454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4412,7 +4464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4428,7 +4480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4441,7 +4493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4456,7 +4508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4466,11 +4518,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
@@ -4495,7 +4548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4507,7 +4560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4523,7 +4576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4568,6 +4621,12 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -4938,6 +4997,31 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.5"
|
||||
@@ -5982,6 +6066,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -6139,7 +6224,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6359,9 +6444,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",
|
||||
@@ -6459,11 +6544,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+8
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -50,15 +50,18 @@ 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" }
|
||||
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
|
||||
web-time = "1.1"
|
||||
|
||||
@@ -286,3 +286,34 @@ The Font Software may be sold as part of a larger software package but no copy o
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -168,6 +168,16 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
|
||||
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
|
||||
new tracks. **Setup › Keyboard Shortcuts** lists them all.
|
||||
|
||||
In **New Tracks**, each row has its own count, format, type, Samples/Ticks timebase and name.
|
||||
Use **+** to add a row below or **−** to remove it; **Create** makes all rows in order, with one
|
||||
Undo step (up to 128 tracks per creation). The last row stays available. The dropdown widths
|
||||
fit their longest option and remain fixed when the selection changes.
|
||||
|
||||
On macOS, **⌘←/→** changes the active row's format, **⌘↑/↓** its type (also with **⌃**),
|
||||
and **⌥⌘↑/↓** its timebase. **⇧⌃⌘↓/↑** adds/removes the active row, also with **⌥**;
|
||||
**⇧⌘↓/↑** works too. Windows/Linux use **Ctrl** for **⌘**. The active row follows the
|
||||
field you click. A new row starts with its count focused and selected; Enter creates and Escape cancels.
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
|
||||
@@ -218,7 +228,8 @@ soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
|
||||
|
||||
See [`docs/cli.md`](docs/cli.md) for the command-line tool (subcommands, exit codes, environment
|
||||
variables, man pages and completions), [`docs/control-protocol.md`](docs/control-protocol.md) and
|
||||
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command.
|
||||
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command (`--json` for each
|
||||
one's parameters and details, as JSON).
|
||||
|
||||
Open **SoundCraft › Session Audio Health** to check loaded audio availability, sample-rate
|
||||
mismatches and clip source bounds across all playlists, including alternate takes. The report
|
||||
@@ -340,7 +351,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 |
|
||||
|
||||
@@ -25,6 +25,7 @@ soundcraft-dsp.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
clap.workspace = true
|
||||
same-file.workspace = true
|
||||
|
||||
|
||||
@@ -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)]
|
||||
@@ -167,6 +168,12 @@ pub(crate) struct CommandsArgs {
|
||||
/// Only the commands whose id, or label in any case, contains this text
|
||||
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
||||
pub(crate) filter: Option<String>,
|
||||
/// Print a JSON array of each command's details, as describe prints them
|
||||
#[arg(long)]
|
||||
pub(crate) json: bool,
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long, requires = "json")]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Print one engine command's details as JSON
|
||||
|
||||
@@ -7,11 +7,15 @@ use std::io::Write;
|
||||
pub(crate) fn commands(args: &CommandsArgs, out: &mut dyn Write) {
|
||||
let filter = args.filter.clone().unwrap_or_default();
|
||||
let e = Engine::default();
|
||||
for c in soundcraft_engine::command_specs() {
|
||||
if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
let i = c.info(&e);
|
||||
let infos = soundcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| filter.is_empty() || c.id.contains(&filter) || c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()))
|
||||
.map(|c| c.info(&e));
|
||||
if args.json {
|
||||
print_json(out, &infos.collect::<Vec<_>>(), args.compact);
|
||||
return;
|
||||
}
|
||||
for i in infos {
|
||||
outln!(out, "{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
|
||||
let bytes = std::fs::read(input).map_err(|source| CliError::File { path: input.clone(), source })?;
|
||||
let ext = input.extension().and_then(std::ffi::OsStr::to_str);
|
||||
let (info, mut buf) = soundcraft_audio_io::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?;
|
||||
let source_rate = buf.sample_rate;
|
||||
if let Some(rate) = rate {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
|
||||
buf.sample_rate = rate;
|
||||
@@ -30,13 +31,28 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
|
||||
let Some(format) = soundcraft_audio_io::FileFormat::from_write_extension(&out_ext) else {
|
||||
return Err(ConvertError::OutputFormat { output: output.clone(), ext: out_ext }.into());
|
||||
};
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
|
||||
// A BWF source keeps its `bext` (written for WAV output only). The time reference counts
|
||||
// samples, so it moves to the output rate.
|
||||
let bwf = info.bwf.map(|mut b| {
|
||||
b.time_reference = at_rate(b.time_reference, source_rate, buf.sample_rate);
|
||||
b
|
||||
});
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?;
|
||||
write_replacing(&args.write_path(), &bytes).map_err(|source| CliError::File { path: output.clone(), source })?;
|
||||
outln!(out, "wrote {} ({} ch, {} Hz, {:.2} s)", output.display(), buf.num_channels(), buf.sample_rate, buf.duration_secs());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The sample position `pos` at `from` Hz as the nearest sample at `to` Hz.
|
||||
fn at_rate(pos: u64, from: u32, to: u32) -> u64 {
|
||||
if from == 0 || from == to {
|
||||
return pos;
|
||||
}
|
||||
let scaled = (u128::from(pos) * u128::from(to) + u128::from(from) / 2) / u128::from(from);
|
||||
u64::try_from(scaled).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Writes `bytes` to a new file next to `target`, flushes it and renames it over `target`, so `target`
|
||||
/// (the input, for --in-place) holds either its old or its new contents. The new file is removed when
|
||||
/// writing or renaming fails.
|
||||
|
||||
@@ -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())]
|
||||
|
||||
@@ -11,6 +11,7 @@ mod catalog;
|
||||
mod convert;
|
||||
mod error;
|
||||
mod info;
|
||||
pub mod logger;
|
||||
mod mcp;
|
||||
mod session;
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! The command line's logger. Without one, every `log::warn!` and `log::error!` in the workspace
|
||||
//! was dropped: a plugin that failed to load just vanished from `engine.clap_plugins`, and nothing
|
||||
//! said why. Warnings and errors now go to standard error as `warning: <message>` and
|
||||
//! `error: <message>`, like the CLI's own errors; standard output keeps only the results.
|
||||
//! `RUST_LOG` stays a desktop app variable (docs/cli.md), so the level is fixed.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
struct StderrLogger;
|
||||
|
||||
impl log::Log for StderrLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= log::Level::Warn
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if self.enabled(record.metadata()) {
|
||||
// A closed standard error is not worth failing a command over.
|
||||
let _ = std::io::stderr().write_all(line(record.level(), &record.args().to_string()).as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// One record as the CLI prints it.
|
||||
fn line(level: log::Level, message: &str) -> String {
|
||||
let label = if level == log::Level::Error { "error" } else { "warning" };
|
||||
format!("{label}: {}\n", message.strip_suffix('\n').unwrap_or(message))
|
||||
}
|
||||
|
||||
/// Installs the logger. Once per process: `main` calls it, not `run`, which tests call many times.
|
||||
pub fn install() {
|
||||
static LOGGER: StderrLogger = StderrLogger;
|
||||
if log::set_logger(&LOGGER).is_ok() {
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn records_print_like_the_cli_errors() {
|
||||
assert_eq!(line(log::Level::Warn, "cannot load CLAP plugin x.clap: bad file\n"), "warning: cannot load CLAP plugin x.clap: bad file\n");
|
||||
assert_eq!(line(log::Level::Error, "panic: boom"), "error: panic: boom\n");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
fn main() {
|
||||
soundcraft_cli::logger::install();
|
||||
// `args_os`, not `args`: a non-Unicode argument must reach clap, not panic before anything is printed.
|
||||
let code = soundcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
|
||||
// The subcommand's engine and its plugin instances are gone by now. This is process teardown,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat};
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id()));
|
||||
@@ -93,3 +93,31 @@ fn convert_dithers_when_reducing_depth_unless_told_not_to() {
|
||||
assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_keeps_the_bwf_time_stamp() {
|
||||
let dir = tmpdir("bext");
|
||||
let src = dir.join("take.wav");
|
||||
// 01:00:00:00 at 48 kHz.
|
||||
let bwf = BwfInfo {
|
||||
description: "take 3".into(),
|
||||
originator: "recorder".into(),
|
||||
origination_date: "2026-11-03".into(),
|
||||
origination_time: "10:00:00".into(),
|
||||
time_reference: 172_800_000,
|
||||
..Default::default()
|
||||
};
|
||||
let buf = AudioBuffer::new(48_000, 1, 4800);
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: Some(bwf.clone()) };
|
||||
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
let bext = |p: &Path| soundcraft_audio_io::probe(&std::fs::read(p).unwrap(), None).unwrap().bwf;
|
||||
|
||||
let out = dir.join("16.wav");
|
||||
convert(&src, &out, &["--bit-depth", "16"]);
|
||||
assert_eq!(bext(&out), Some(bwf.clone()));
|
||||
// Resampled, the stamp still points at 01:00:00:00: 158,760,000 samples at 44.1 kHz.
|
||||
let out = dir.join("44k.wav");
|
||||
convert(&src, &out, &["--rate", "44100"]);
|
||||
assert_eq!(bext(&out), Some(BwfInfo { time_reference: 158_760_000, ..bwf }));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,25 @@ fn assert_refused(got: &Invoke, port: u16) {
|
||||
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
|
||||
}
|
||||
|
||||
// ---- logging ----
|
||||
|
||||
/// Warnings SoundCraft logs (a plugin that fails to load, a backup it can't write) go to standard
|
||||
/// error. Without a logger they were dropped.
|
||||
#[test]
|
||||
fn logged_warnings_reach_standard_error() {
|
||||
let dir = sandbox("log");
|
||||
let first = invoke(&dir, &["run", "--save", "s.scraft"]);
|
||||
assert_eq!(first.code, 0, "{}", first.stderr);
|
||||
// A file where the backups folder should go: the next save that changes the session can't
|
||||
// back the old one up.
|
||||
std::fs::write(dir.join("Backups"), b"not a folder").unwrap();
|
||||
let again = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", r#"track.new={"count":1}"#, "--save", "s.scraft", "--in-place"]);
|
||||
assert_eq!(again.code, 0, "{}", again.stderr);
|
||||
assert!(again.stderr.contains("warning: no backup of"), "{}", again.stderr);
|
||||
assert!(!again.stdout.contains("no backup"), "results only on standard output: {}", again.stdout);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---- unknown flags ----
|
||||
|
||||
#[test]
|
||||
@@ -238,13 +257,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 +534,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 +561,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);
|
||||
}
|
||||
|
||||
@@ -253,3 +253,31 @@ fn script_bounce_without_range_covers_the_session() {
|
||||
assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `commands --json` lists every command with the details `describe` prints for one (#744).
|
||||
#[test]
|
||||
fn commands_json_lists_what_describe_prints() {
|
||||
let json = |o: &Output| -> Value {
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
serde_json::from_slice(&o.stdout).unwrap()
|
||||
};
|
||||
let all = json(&run(&["commands", "--json"]));
|
||||
let all = all.as_array().unwrap();
|
||||
let table = run(&["commands"]);
|
||||
assert_eq!(all.len(), String::from_utf8_lossy(&table.stdout).lines().count());
|
||||
let bounce = json(&run(&["describe", "file.bounce_mix"]));
|
||||
assert!(all.contains(&bounce), "{bounce}");
|
||||
assert!(all.iter().all(|c| c["id"].is_string() && c["params"].is_string() && c["menu"].is_array()));
|
||||
// The filter applies, and --compact prints one line.
|
||||
let compact = run(&["commands", "bounce", "--json", "--compact"]);
|
||||
assert_eq!(String::from_utf8_lossy(&compact.stdout).lines().count(), 1);
|
||||
let some = json(&compact);
|
||||
let some = some.as_array().unwrap();
|
||||
assert!(some.contains(&bounce) && some.len() < all.len(), "{some:?}");
|
||||
// No match is an empty list, still JSON.
|
||||
assert_eq!(json(&run(&["commands", "zzzzqq", "--json"])), Value::Array(Vec::new()));
|
||||
// --compact shapes JSON output only.
|
||||
let bad = run(&["commands", "--compact"]);
|
||||
assert_eq!(bad.status.code(), Some(2));
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("--json"), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -151,7 +151,7 @@ fn every_subcommand_has_help_listing_its_arguments() {
|
||||
),
|
||||
("run", "run [OPTIONS]", &["--cmd <ID[=JSON]>"]),
|
||||
("script", "script [OPTIONS] <FILE>", &["<FILE>", "--keep-going"]),
|
||||
("commands", "commands [FILTER]", &["[FILTER]"]),
|
||||
("commands", "commands [OPTIONS] [FILTER]", &["[FILTER]", "--json", "--compact"]),
|
||||
("describe", "describe [OPTIONS] <ID>", &["<ID>", "--compact"]),
|
||||
(
|
||||
"app",
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -27,9 +27,13 @@ serde_json.workspace = true
|
||||
log.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Preserve X11 fallback while enabling font-free Wayland client-side controls.
|
||||
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd.workspace = true
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,7 @@ impl NativeMenu {
|
||||
let mut native = Self { menu, entries: Vec::new(), events, last_sync: None };
|
||||
let app_menu = Submenu::new("SoundCraft", true);
|
||||
native.add_item(app, &app_menu, "About SoundCraft", "", Some("window.about".into()))?;
|
||||
native.add_item(app, &app_menu, "Preferences...", "", Some("setup.preferences".into()))?;
|
||||
native.add_item(app, &app_menu, "Session Info", "", Some("window.session_info".into()))?;
|
||||
native.add_item(app, &app_menu, "Session Audio Health", "", Some("window.audio_health".into()))?;
|
||||
app_menu.append(&PredefinedMenuItem::separator())?;
|
||||
@@ -94,8 +95,8 @@ impl NativeMenu {
|
||||
&& let Some(command) = &entry.command
|
||||
&& menus::item_state(app, &entry.path, Some(command)).0
|
||||
{
|
||||
if entry.path.is_empty() && command.starts_with("window.") {
|
||||
// The application menu's About / Session Info open their window (never close it).
|
||||
if entry.path.is_empty() && (command.starts_with("window.") || command == "setup.preferences") {
|
||||
// Application-menu window entries open their window rather than toggling it.
|
||||
let _ = app.run(command, serde_json::json!({"value": true}));
|
||||
} else {
|
||||
menus::invoke_menu(app, command, &entry.path);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -110,6 +110,15 @@ fn zero_channels_is_malformed() {
|
||||
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_negative_channel_count_is_malformed() {
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 8] = 0x80;
|
||||
aiff[comm + 9] = 0;
|
||||
assert!(probe(&aiff, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_sample_rate_is_malformed() {
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
|
||||
@@ -208,6 +217,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 +375,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 +386,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]
|
||||
|
||||
@@ -27,6 +27,7 @@ use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_p
|
||||
use clap_sys::ext::params::{
|
||||
CLAP_EXT_PARAMS, clap_host_params, clap_param_clear_flags, clap_param_info, clap_param_rescan_flags, clap_plugin_params,
|
||||
};
|
||||
use clap_sys::ext::render::{CLAP_EXT_RENDER, CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render};
|
||||
use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state, clap_plugin_state};
|
||||
use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory};
|
||||
use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR};
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::{
|
||||
clap_host, clap_plugin_audio_ports, clap_plugin_entry, clap_plugin_factory, clap_plugin_gui, clap_plugin_latency, clap_plugin_note_ports,
|
||||
clap_plugin_params, clap_plugin_state, clap_version_is_compatible, read_cstr, scan,
|
||||
};
|
||||
use super::{CLAP_EXT_RENDER, clap_plugin_render};
|
||||
|
||||
// ---- loaded binaries -----------------------------------------------------------------------
|
||||
|
||||
@@ -42,6 +43,16 @@ fn loaded() -> &'static Mutex<HashMap<PathBuf, Arc<Bundle>>> {
|
||||
L.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// A libloading error with the system's reason. libloading's own text is only "dlopen failed"
|
||||
/// (or "dlsym failed", "LoadLibraryExW failed"); the reason (`dlerror`, the Windows error) is its
|
||||
/// source.
|
||||
fn with_reason(e: &libloading::Error) -> String {
|
||||
match std::error::Error::source(e) {
|
||||
Some(why) => format!("{e}: {why}"),
|
||||
None => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
/// Loads (once per path, then cached for the life of the process) and initialises a `.clap`.
|
||||
pub fn load(path: &Path) -> Result<Arc<Bundle>, ClapError> {
|
||||
@@ -59,12 +70,12 @@ impl Bundle {
|
||||
let exe = scan::bundle_executable(path).ok_or_else(|| err("no loadable binary".into()))?;
|
||||
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
|
||||
// hosting plugins; we only load files found in CLAP plugin folders or named explicitly.
|
||||
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(e.to_string()))?;
|
||||
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(with_reason(&e)))?;
|
||||
// SAFETY: `clap_entry` is the CLAP-mandated exported data symbol of type
|
||||
// `const clap_plugin_entry`; the symbol's address is therefore a `*const clap_plugin_entry`.
|
||||
let entry: *const clap_plugin_entry = match unsafe { lib.get::<*const clap_plugin_entry>(b"clap_entry\0") } {
|
||||
Ok(sym) => *sym,
|
||||
Err(e) => return Err(err(format!("not a CLAP plugin: {e}"))),
|
||||
Err(e) => return Err(err(format!("not a CLAP plugin (no clap_entry): {}", with_reason(&e)))),
|
||||
};
|
||||
if entry.is_null() {
|
||||
return Err(err("null clap_entry".into()));
|
||||
@@ -178,6 +189,7 @@ impl Bundle {
|
||||
latency: std::ptr::null(),
|
||||
notes: std::ptr::null(),
|
||||
state_ext: std::ptr::null(),
|
||||
render: std::ptr::null(),
|
||||
gui: Arc::new(GuiLink::default()),
|
||||
raw: Vec::with_capacity(EVENT_CAP),
|
||||
out_params: Vec::with_capacity(EVENT_CAP),
|
||||
@@ -205,6 +217,7 @@ impl Bundle {
|
||||
inst.latency = inst.extension::<clap_plugin_latency>(CLAP_EXT_LATENCY);
|
||||
inst.notes = inst.extension::<clap_plugin_note_ports>(CLAP_EXT_NOTE_PORTS);
|
||||
inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
|
||||
inst.render = inst.extension::<clap_plugin_render>(CLAP_EXT_RENDER);
|
||||
let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
|
||||
inst.gui = Arc::new(GuiLink {
|
||||
inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),
|
||||
|
||||
@@ -17,9 +17,11 @@ pub(crate) fn on_main_thread() -> bool {
|
||||
unsafe { pthread_main_np() == 1 }
|
||||
}
|
||||
|
||||
/// True on the process's main thread: Rust names it "main", and the app runs its UI there (the
|
||||
/// rule `hostwin` and the VST3 host use). CLAP makes `clap.gui` main-thread only on every platform.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn on_main_thread() -> bool {
|
||||
true
|
||||
std::thread::current().name() == Some("main")
|
||||
}
|
||||
|
||||
/// The windowing API of this platform.
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::{
|
||||
clap_plugin, clap_plugin_audio_ports, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_process,
|
||||
read_name,
|
||||
};
|
||||
use super::{CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render};
|
||||
|
||||
// ---- audio buffers -------------------------------------------------------------------------
|
||||
|
||||
@@ -99,6 +100,7 @@ pub(crate) struct Instance {
|
||||
pub(super) latency: *const clap_plugin_latency,
|
||||
pub(super) notes: *const clap_plugin_note_ports,
|
||||
pub(super) state_ext: *const clap_plugin_state,
|
||||
pub(super) render: *const clap_plugin_render,
|
||||
pub(super) gui: Arc<GuiLink>,
|
||||
pub(super) raw: Vec<RawEvent>,
|
||||
/// Parameter values the plugin reported through output events in the current `process`.
|
||||
@@ -158,6 +160,16 @@ impl Instance {
|
||||
unsafe { load(self.plugin, &stream) }
|
||||
}
|
||||
|
||||
/// `clap_plugin_render.set`: offline (no realtime pressure) or realtime. `false` without the
|
||||
/// extension or when the plugin can't switch.
|
||||
pub fn set_render_mode(&self, offline: bool) -> bool {
|
||||
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
|
||||
let Some(set) = (unsafe { self.render.as_ref() }).and_then(|r| r.set) else { return false };
|
||||
let mode = if offline { CLAP_RENDER_OFFLINE } else { CLAP_RENDER_REALTIME };
|
||||
// SAFETY: valid instance, called from the thread that owns it.
|
||||
unsafe { set(self.plugin, mode) }
|
||||
}
|
||||
|
||||
/// Runs `on_main_thread` if the plugin asked for a callback.
|
||||
pub fn service_callback(&mut self) {
|
||||
if self.state.callback.swap(false, Ordering::Relaxed)
|
||||
|
||||
@@ -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.
|
||||
@@ -222,10 +222,33 @@ mod tests {
|
||||
assert!(plugin_info("reverb").is_none());
|
||||
}
|
||||
|
||||
/// A new directory that this test alone created, readable only by its user on Unix. Creation
|
||||
/// fails if the path already exists: the test then loads native libraries from it, so it must
|
||||
/// never reuse a directory someone else could have put a library in. It lives next to the test
|
||||
/// binary (in the build directory), not under the temp dir: `TMPDIR`/`TEMP` can name a shared
|
||||
/// directory without a sticky bit or with inheritable write access, where another user could
|
||||
/// rename or populate it. Anyone who can write to the build directory can already replace the
|
||||
/// test binary itself, so this parent adds no new trust.
|
||||
fn fresh_private_dir(prefix: &str) -> std::path::PathBuf {
|
||||
let exe = std::env::current_exe().expect("test binary path");
|
||||
let parent = exe.parent().expect("test binary has a parent directory");
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |t| t.as_nanos());
|
||||
let d = parent.join(format!("{prefix}-{}-{nanos}", std::process::id()));
|
||||
#[cfg(unix)]
|
||||
let builder = {
|
||||
let mut b = std::fs::DirBuilder::new();
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
|
||||
b
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let builder = std::fs::DirBuilder::new();
|
||||
builder.create(&d).unwrap_or_else(|e| panic!("cannot create a fresh test directory {}: {e}", d.display()));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_files_are_errors_not_crashes() {
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let d = fresh_private_dir("soundcraft-clap-garbage");
|
||||
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
|
||||
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
|
||||
std::fs::write(d.join("empty.clap"), b"").unwrap();
|
||||
@@ -239,4 +262,17 @@ mod tests {
|
||||
assert!(instantiate("clap:does.not.exist").is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A binary the system refuses to load reports the system's reason (missing library, wrong
|
||||
/// glibc, not an ELF file), not just "dlopen failed".
|
||||
#[test]
|
||||
fn a_binary_that_does_not_load_says_why() {
|
||||
let d = fresh_private_dir("soundcraft-clap-reason");
|
||||
let f = d.join("garbage.clap");
|
||||
std::fs::write(&f, b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
|
||||
let msg = load_bundle(&f).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let reason = msg.split_once("failed: ").map_or("", |(_, r)| r.trim());
|
||||
assert!(!reason.is_empty(), "no reason in: {msg}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,11 @@ impl Plugin for ClapPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_offline(&mut self, offline: bool) {
|
||||
// `clap.render`: plugins without it render the same way either way.
|
||||
self.inst.set_render_mode(offline);
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.inst.reset();
|
||||
self.queue.retain(|e| matches!(e, Event::Param { .. }));
|
||||
|
||||
@@ -150,7 +150,9 @@ mod tests {
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-clap-scan-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
// Created exclusively: a folder still there after the cleanup (someone else's) would
|
||||
// let a link inside it redirect the test's writes.
|
||||
std::fs::create_dir(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
mod common;
|
||||
|
||||
use soundcraft_dsp::Plugin;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const GAIN: &str = "clap:org.soundcraft.test.gain";
|
||||
|
||||
@@ -14,12 +15,31 @@ fn block(p: &mut Box<dyn Plugin>) -> Vec<Vec<f32>> {
|
||||
io
|
||||
}
|
||||
|
||||
/// The warnings logged, to tell which way the host went.
|
||||
static WARNINGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
struct Warnings;
|
||||
|
||||
impl log::Log for Warnings {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= log::Level::Warn
|
||||
}
|
||||
fn log(&self, r: &log::Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
WARNINGS.lock().unwrap().push(r.args().to_string());
|
||||
}
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// nextest lists custom-harness tests with `--list`; each binary here is one test.
|
||||
if std::env::args().any(|a| a == "--list") {
|
||||
println!("editor_main_thread: test");
|
||||
return;
|
||||
}
|
||||
log::set_logger(&Warnings).expect("logger");
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
common::fixture_dir();
|
||||
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
@@ -32,6 +52,26 @@ fn main() {
|
||||
// block and is reported (in SoundCraft units) by idle.
|
||||
let io = block(&mut p);
|
||||
assert!((io[0][10] - 0.25).abs() < 1e-6, "{}", io[0][10]);
|
||||
// Only the main thread drives the editor, on every platform (#901). Another thread, named or
|
||||
// not, is refused: it neither takes the edit idle reports nor closes or reopens the editor.
|
||||
for name in [None, Some("soundcraft-ui")] {
|
||||
let mut other = p.editor().expect("editor handle");
|
||||
let thread = match name {
|
||||
Some(n) => std::thread::Builder::new().name(n.to_string()),
|
||||
None => std::thread::Builder::new(),
|
||||
};
|
||||
let seen = thread
|
||||
.spawn(move || {
|
||||
let edits = other.idle();
|
||||
other.close();
|
||||
(edits, other.open().map_err(|e| e.contains("main thread")))
|
||||
})
|
||||
.expect("spawn")
|
||||
.join()
|
||||
.expect("no panic");
|
||||
assert_eq!(seen, (Vec::new(), Err(true)), "{name:?}");
|
||||
assert!(ed.is_open(), "{name:?}");
|
||||
}
|
||||
assert_eq!(ed.idle(), vec![("7".to_string(), 0.25)]);
|
||||
assert!(ed.idle().is_empty(), "drained");
|
||||
ed.close();
|
||||
@@ -49,5 +89,17 @@ fn main() {
|
||||
assert!(ed.open().is_err());
|
||||
assert!(ed.idle().is_empty());
|
||||
ed.close();
|
||||
// #901: a plugin dropped on another thread with its editor open (an audio callback dropped
|
||||
// by the device's thread) does not destroy the editor there, clap.gui being main-thread only:
|
||||
// it warns and leaves it.
|
||||
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
let mut ed = p.editor().expect("editor handle");
|
||||
ed.open().expect("open on the main thread");
|
||||
std::thread::Builder::new().name("audio".into()).spawn(move || drop(p)).expect("spawn").join().expect("no panic");
|
||||
let left = "clap: editor still open while its plugin is destroyed off the main thread";
|
||||
assert!(WARNINGS.lock().unwrap().iter().any(|w| w == left), "{:?}", WARNINGS.lock().unwrap());
|
||||
assert!(!ed.is_open());
|
||||
assert!(ed.open().is_err());
|
||||
println!("editor_main_thread: ok");
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
|
||||
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
|
||||
//! `process` writes 1.0 into every output channel it was given when the host's buffers match the
|
||||
//! advertised layout, -1.0 when they do not.
|
||||
//! advertised layout (2.0 once the host set the offline render mode through `clap.render`), -1.0
|
||||
//! when they do not.
|
||||
//!
|
||||
//! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a
|
||||
//! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI,
|
||||
@@ -27,6 +28,7 @@ use clap_sys::ext::latency::*;
|
||||
use clap_sys::ext::log::*;
|
||||
use clap_sys::ext::note_ports::*;
|
||||
use clap_sys::ext::params::*;
|
||||
use clap_sys::ext::render::*;
|
||||
use clap_sys::factory::plugin_factory::*;
|
||||
use clap_sys::host::clap_host;
|
||||
use clap_sys::id::clap_id;
|
||||
@@ -100,8 +102,12 @@ struct Fx {
|
||||
active: bool,
|
||||
processing: bool,
|
||||
gui_created: bool,
|
||||
/// The thread that created the GUI (the host's main thread).
|
||||
gui_thread: Option<std::thread::ThreadId>,
|
||||
gui_visible: bool,
|
||||
gui_edit_pending: bool,
|
||||
/// `clap.render` set the offline mode (wide fixtures only).
|
||||
offline: bool,
|
||||
}
|
||||
|
||||
unsafe fn fx<'a>(p: *const clap_plugin) -> &'a mut Fx {
|
||||
@@ -126,7 +132,10 @@ unsafe extern "C" fn init(p: *const clap_plugin) -> bool {
|
||||
}
|
||||
|
||||
unsafe extern "C" fn destroy(p: *const clap_plugin) {
|
||||
assert!(!unsafe { fx(p) }.gui_created, "plugin destroyed with its GUI alive");
|
||||
let f = unsafe { fx(p) };
|
||||
// A host destroying the plugin off the GUI's thread can't destroy the GUI there (clap.gui is
|
||||
// main-thread only), so it may leave it; on the GUI's thread it must destroy it first.
|
||||
assert!(!f.gui_created || f.gui_thread != Some(std::thread::current().id()), "plugin destroyed with its GUI alive");
|
||||
let data = unsafe { (*p).plugin_data } as *mut Fx;
|
||||
drop(unsafe { Box::from_raw(data) });
|
||||
}
|
||||
@@ -189,8 +198,8 @@ fn apply(f: &mut Fx, h: &clap_event_header) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill every output channel with 1.0 when all ports have the wide layout, else -1.0.
|
||||
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
|
||||
/// Fill every output channel with `fit` when all ports have the wide layout, else -1.0.
|
||||
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize, fit: f32) {
|
||||
let fits = |bufs: *const clap_audio_buffer, n: u32| {
|
||||
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
|
||||
};
|
||||
@@ -200,7 +209,7 @@ unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
|
||||
for c in 0..out.channel_count as usize {
|
||||
let o = unsafe { *out.data32.add(c) };
|
||||
for i in 0..n {
|
||||
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
|
||||
unsafe { *o.add(i) = if ok { fit } else { -1.0 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,7 +261,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
|
||||
let ev = unsafe { &*pr.in_events };
|
||||
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
|
||||
if let Some((ports, channels)) = f.wide {
|
||||
unsafe { fill_wide(pr, ports, channels, n) };
|
||||
unsafe { fill_wide(pr, ports, channels, n, if f.offline { 2.0 } else { 1.0 }) };
|
||||
return CLAP_PROCESS_CONTINUE;
|
||||
}
|
||||
if pr.audio_outputs_count != 1 {
|
||||
@@ -488,6 +497,7 @@ unsafe extern "C" fn gui_create(p: *const clap_plugin, _api: *const c_char, floa
|
||||
let f = unsafe { fx(p) };
|
||||
assert!(floating && !f.gui_created, "create");
|
||||
f.gui_created = true;
|
||||
f.gui_thread = Some(std::thread::current().id());
|
||||
true
|
||||
}
|
||||
|
||||
@@ -543,6 +553,17 @@ static GUI: clap_plugin_gui = clap_plugin_gui {
|
||||
hide: Some(gui_hide),
|
||||
};
|
||||
|
||||
unsafe extern "C" fn render_hard_realtime(_p: *const clap_plugin) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
unsafe extern "C" fn render_set(p: *const clap_plugin, mode: clap_plugin_render_mode) -> bool {
|
||||
unsafe { fx(p) }.offline = mode == CLAP_RENDER_OFFLINE;
|
||||
true
|
||||
}
|
||||
|
||||
static RENDER: clap_plugin_render = clap_plugin_render { has_hard_realtime_requirement: Some(render_hard_realtime), set: Some(render_set) };
|
||||
|
||||
unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) -> *const c_void {
|
||||
let id = unsafe { CStr::from_ptr(id) };
|
||||
let synth = unsafe { fx(p) }.synth;
|
||||
@@ -558,6 +579,8 @@ unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) ->
|
||||
&STATE as *const _ as *const c_void
|
||||
} else if id == CLAP_EXT_GUI && !synth {
|
||||
&GUI as *const _ as *const c_void
|
||||
} else if id == CLAP_EXT_RENDER && unsafe { fx(p) }.wide.is_some() {
|
||||
&RENDER as *const _ as *const c_void
|
||||
} else {
|
||||
std::ptr::null()
|
||||
}
|
||||
@@ -621,8 +644,10 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
|
||||
active: false,
|
||||
processing: false,
|
||||
gui_created: false,
|
||||
gui_thread: None,
|
||||
gui_visible: false,
|
||||
gui_edit_pending: false,
|
||||
offline: false,
|
||||
});
|
||||
let raw = Box::into_raw(b);
|
||||
unsafe {
|
||||
|
||||
@@ -130,6 +130,19 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_renders_set_the_render_mode() {
|
||||
fixture_dir();
|
||||
for (offline, want) in [(false, 1.0), (true, 2.0)] {
|
||||
let mut p = soundcraft_clap_host::create("clap:org.soundcraft.test.wide16").unwrap();
|
||||
p.set_offline(offline);
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| x == want), "offline {offline}: {:?}", &io[0][..4]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_instances_create_and_drop_cleanly() {
|
||||
fixture_dir();
|
||||
@@ -202,14 +215,12 @@ fn state_reader_reads_while_another_thread_processes() {
|
||||
#[test]
|
||||
fn editors_refuse_to_open_off_the_main_thread() {
|
||||
fixture_dir();
|
||||
// Test threads are never the process main thread.
|
||||
// Test threads are never the process main thread, on any platform (#901).
|
||||
let mut p = soundcraft_clap_host::create(GAIN).unwrap();
|
||||
let mut ed = p.editor().expect("the gain fixture has clap.gui");
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(ed.open().unwrap_err().contains("main thread"));
|
||||
assert!(p.open_editor().is_err());
|
||||
assert!(!ed.is_open());
|
||||
}
|
||||
assert!(ed.open().unwrap_err().contains("main thread"));
|
||||
assert!(p.open_editor().is_err());
|
||||
assert!(!ed.is_open());
|
||||
ed.close();
|
||||
p.close_editor();
|
||||
}
|
||||
|
||||
@@ -195,6 +195,11 @@ pub struct Transport {
|
||||
pub trait Plugin: Send {
|
||||
fn info(&self) -> &'static PluginInfo;
|
||||
fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize);
|
||||
/// The plugin renders offline (a bounce: faster or slower than realtime, no deadline) rather
|
||||
/// than in realtime. Hosted plugins pass it on (VST3 `processMode`, CLAP `clap.render`): some
|
||||
/// use better algorithms then, or wait for what they load instead of playing on without it.
|
||||
/// Called before `prepare`; plugins are realtime until told otherwise.
|
||||
fn set_offline(&mut self, _offline: bool) {}
|
||||
fn reset(&mut self);
|
||||
/// Sets a parameter (clamped into range; NaN becomes the default). `false` if `id` is unknown.
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+146
-12
@@ -212,6 +212,11 @@ impl Autosave {
|
||||
let audio_dir = self.dir.join("Audio Files");
|
||||
let mut s = session.clone();
|
||||
let mut copies = BTreeMap::new();
|
||||
// Copies made in this write, by the audio they hold: sources sharing one decoded buffer
|
||||
// (a session may name one file under many ids) share one file.
|
||||
let mut written: BTreeMap<*const SourceAudio, String> = BTreeMap::new();
|
||||
// Copies still to make, written once the size is checked; their numbers are taken then too.
|
||||
let (mut fresh, mut next) = (Vec::new(), self.next);
|
||||
for src in &mut s.sources {
|
||||
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
|
||||
let audio = s.pool.get(src.id);
|
||||
@@ -222,26 +227,37 @@ impl Autosave {
|
||||
continue;
|
||||
}
|
||||
let Some(audio) = audio else { continue };
|
||||
let name = match self.copies.remove(&src.id) {
|
||||
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
|
||||
let name = match self.copies.get(&src.id) {
|
||||
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name.clone(),
|
||||
_ if written.contains_key(&Arc::as_ptr(audio)) => written.get(&Arc::as_ptr(audio)).cloned().unwrap_or_default(),
|
||||
_ => {
|
||||
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
|
||||
self.next += 1;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(&name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
|
||||
let name = format!("{} {}.wav", sanitize_name(&src.name), next);
|
||||
next += 1;
|
||||
fresh.push((name.clone(), Arc::clone(audio)));
|
||||
name
|
||||
}
|
||||
};
|
||||
src.path = audio_dir.join(&name).to_string_lossy().into_owned();
|
||||
src.unsaved = true;
|
||||
written.insert(Arc::as_ptr(audio), name.clone());
|
||||
copies.insert(src.id, (Arc::downgrade(audio), name));
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// An autosave too large to recover is not written, nor its new copies: the last autosave
|
||||
// stays as it was, and the failure is reported (#892).
|
||||
crate::io::session_size("the autosave", text.len() as u64, true)?;
|
||||
self.next = next;
|
||||
for (name, audio) in &fresh {
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
|
||||
}
|
||||
write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?;
|
||||
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
|
||||
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
|
||||
let _ = std::fs::remove_file(audio_dir.join(name));
|
||||
if !self.copies.values().any(|(_, kept)| *kept == name) {
|
||||
let _ = std::fs::remove_file(audio_dir.join(name));
|
||||
}
|
||||
}
|
||||
if let Some(r) = recording {
|
||||
let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
@@ -255,7 +271,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,12 +302,11 @@ 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) => {
|
||||
let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default();
|
||||
let named = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string));
|
||||
let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
|
||||
let name = saved_name(&dir.join(SESSION)).unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
|
||||
out.push(Leftover { dir, name, origin, saved, recording });
|
||||
}
|
||||
_ => {
|
||||
@@ -303,6 +318,21 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The name of the session saved in `file`, read without keeping the rest of it; `None` when the
|
||||
/// file is missing, not a session, or too large to open (#892).
|
||||
fn saved_name(file: &Path) -> Option<String> {
|
||||
use std::io::Read as _;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Named {
|
||||
name: String,
|
||||
}
|
||||
if std::fs::metadata(file).ok()?.len() > crate::io::session_limit() {
|
||||
return None;
|
||||
}
|
||||
let read = std::fs::File::open(file).ok()?.take(crate::io::session_limit());
|
||||
serde_json::from_reader::<_, Named>(std::io::BufReader::new(read)).ok().map(|n| n.name)
|
||||
}
|
||||
|
||||
/// Open `left` as an unsaved version of the session it came from, so Save writes it back there,
|
||||
/// with the takes of a recording that never finished placed as Stop would have. Returns the
|
||||
/// media that couldn't be loaded.
|
||||
@@ -450,6 +480,27 @@ mod tests {
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
|
||||
|
||||
/// Recovery loads document edits without re-enabling live inputs from an autosave.
|
||||
#[test]
|
||||
fn recovered_live_input_states_start_disabled() {
|
||||
let root = folder("live-input-recovery");
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let track = e.session().tracks[0].id;
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
|
||||
let mut autosave = Autosave::create(&root, "Live inputs").unwrap();
|
||||
autosave.write(e.session(), None, None).unwrap();
|
||||
drop(autosave);
|
||||
let mut recovered = Engine::default();
|
||||
recover(&mut recovered, &leftovers(&root)[0]).unwrap();
|
||||
let mixer = &recovered.session().track(track).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert_eq!(mixer.volume_db, -6.0);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -506,6 +557,25 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_sharing_one_buffer_share_one_copy() {
|
||||
let dir = folder("autosave-aliases");
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
// Nine more source ids over the same decoded audio, as a session file may declare.
|
||||
let s = e.session_mut();
|
||||
let (src, audio) = (s.sources[0].clone(), s.pool.get(s.sources[0].id).cloned().unwrap());
|
||||
for _ in 0..9 {
|
||||
let id = SourceId(s.alloc());
|
||||
s.sources.push(soundcraft_model::Source { id, ..src.clone() });
|
||||
s.pool.insert(id, audio.clone());
|
||||
}
|
||||
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
|
||||
a.write(e.session(), None, None).unwrap();
|
||||
assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1, "one file for the ten ids");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_recorded_after_undo_gets_its_own_copy() {
|
||||
let dir = folder("autosave-undo");
|
||||
@@ -672,6 +742,70 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A test folder, removed when the test ends, passed or not.
|
||||
struct Gone(PathBuf);
|
||||
|
||||
impl Drop for Gone {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_autosave_too_large_to_open_writes_nothing_and_keeps_the_last() {
|
||||
// #892, with the limit lowered for this test: a refused autosave makes no audio copies
|
||||
// and keeps the last autosave and its copies as they were.
|
||||
let dir = Gone(folder("autosave-too-large"));
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
let mut a = Autosave::create(&dir.0.join("Autosave"), "Song").unwrap();
|
||||
a.write(e.session(), None, None).unwrap();
|
||||
let state = |a: &Autosave| {
|
||||
let mut files: Vec<String> =
|
||||
std::fs::read_dir(a.dir.join("Audio Files")).unwrap().map(|f| f.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
files.sort();
|
||||
let copies: Vec<(SourceId, String)> = a.copies.iter().map(|(id, (_, name))| (*id, name.clone())).collect();
|
||||
(files, copies, a.next, std::fs::read_to_string(a.dir.join(SESSION)).unwrap())
|
||||
};
|
||||
let before = state(&a);
|
||||
record(&mut e, 0.25);
|
||||
{
|
||||
let _limit = crate::io::SessionLimit::set(before.3.len() as u64);
|
||||
for _ in 0..2 {
|
||||
let err = a.write(e.session(), None, None).unwrap_err().to_string();
|
||||
assert!(err.starts_with("the autosave is too large to save"), "{err}");
|
||||
assert!(state(&a) == before);
|
||||
}
|
||||
// The writer thread reports it as it reports any failed autosave.
|
||||
let failure = Arc::new(Mutex::new(None));
|
||||
let pending = Some((e.session_arc(), None, None));
|
||||
let mut w = Writer { root: dir.0.join("Writer"), failure: Arc::clone(&failure), folder: None, pending, last: None, written: Ok(()) };
|
||||
w.write_pending();
|
||||
let reported = failure.lock().unwrap().take().unwrap_or_default();
|
||||
assert!(reported.starts_with("the autosave is too large to save"), "{reported}");
|
||||
}
|
||||
a.write(e.session(), None, None).unwrap();
|
||||
let after = state(&a);
|
||||
assert_eq!((after.0.len(), after.1.len()), (2, 2));
|
||||
assert!(after.0.contains(&before.0[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leftover_is_named_from_its_session_without_reading_a_huge_one() {
|
||||
// #892: the name is read on its own, and an autosave too large to open is not read.
|
||||
let dir = Gone(folder("autosave-names"));
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let file = dir.0.join(SESSION);
|
||||
let s = Session { name: "Song".into(), ..Session::default() };
|
||||
std::fs::write(&file, s.to_json().unwrap()).unwrap();
|
||||
assert_eq!(saved_name(&file).as_deref(), Some("Song"));
|
||||
std::fs::write(&file, r#"{"name": 5}"#).unwrap();
|
||||
assert_eq!(saved_name(&file), None);
|
||||
std::fs::File::create(&file).unwrap().set_len(crate::io::MAX_SESSION_BYTES + 1).unwrap();
|
||||
assert_eq!(saved_name(&file), None);
|
||||
assert_eq!(saved_name(&dir.0.join("none")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_started_together_get_folders_of_their_own() {
|
||||
// #579: the folder name has millisecond resolution, so two sessions created back to back
|
||||
|
||||
@@ -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))),
|
||||
@@ -341,7 +347,10 @@ pub fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
|
||||
|
||||
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
|
||||
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
|
||||
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();
|
||||
// Each clip once, in the order given: a repeated id would otherwise be processed (and,
|
||||
// for a render, re-rendered into another source) once per repeat.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
return arr.iter().filter_map(Value::as_u64).map(ClipId).filter(|id| seen.insert(*id)).collect();
|
||||
}
|
||||
if let Some(n) = p.get("clip").and_then(Value::as_u64) {
|
||||
return vec![ClipId(n)];
|
||||
|
||||
@@ -14,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AudioSuite Process",
|
||||
[],
|
||||
None,
|
||||
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
|
||||
"{process: <built-in plugin id (engine.plugins)> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
|
||||
has_selection,
|
||||
process
|
||||
)]
|
||||
@@ -55,6 +55,9 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
| "speed_ramp"
|
||||
| "dc_offset_removal"
|
||||
);
|
||||
if !known && soundcraft_mix::is_third_party(&name) {
|
||||
return Err(bad("audiosuite.process", format!("`{name}` is a hosted plugin; AudioSuite runs built-in plugins only (engine.plugins)")));
|
||||
}
|
||||
if !known {
|
||||
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
|
||||
}
|
||||
@@ -137,6 +140,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,
|
||||
@@ -227,6 +231,17 @@ mod tests {
|
||||
(crate::Engine::new(s), id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_plugin_ids_are_refused_with_the_reason() {
|
||||
let (mut e, id) = gained_clip();
|
||||
for plugin in ["clap:com.example.reverb", "vst3:00112233445566778899AABBCCDDEEFF", "au:aufx:dely:appl"] {
|
||||
let err = e.execute("audiosuite.process", &json!({"process": plugin, "clips": [id.0]})).unwrap_err().to_string();
|
||||
assert!(err.contains("built-in"), "{plugin}: {err}");
|
||||
}
|
||||
let spec = specs().into_iter().find(|s| s.id == "audiosuite.process").unwrap();
|
||||
assert!(spec.params.contains("<built-in plugin id"), "the help says which ids it takes: {}", spec.params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_and_duplicate_keep_clip_gain() {
|
||||
let (mut e, id) = gained_clip();
|
||||
@@ -246,6 +261,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;
|
||||
}
|
||||
}
|
||||
@@ -424,7 +425,7 @@ fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session();
|
||||
let moves: Vec<(soundcraft_model::ClipId, i64)> =
|
||||
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect();
|
||||
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false, &ids)))).collect();
|
||||
let s = e.session_mut();
|
||||
for (id, d) in &moves {
|
||||
crate::edit::move_clips(s, &[*id], *d, &[]);
|
||||
@@ -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...",
|
||||
@@ -310,6 +301,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
|
||||
cut_track(s, *t, range, sh, follow);
|
||||
}
|
||||
s.edit.selection = soundcraft_time::Range::point(range.start);
|
||||
cb.cut_from = Some(range.start);
|
||||
}
|
||||
let n: usize = cb.tracks.iter().map(Vec::len).sum();
|
||||
e.clipboard = cb;
|
||||
@@ -337,6 +329,7 @@ pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
|
||||
cb
|
||||
}
|
||||
|
||||
/// Pastes the clipboard at an explicit position or at the current edit insertion point.
|
||||
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.paste", p)?;
|
||||
let given = position_param(e, "edit.paste", p, "at")?;
|
||||
@@ -344,9 +337,10 @@ fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
let at = match given {
|
||||
Some(at) => at,
|
||||
None if s.edit.edit_mode == EditMode::GridRelative => {
|
||||
s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset)
|
||||
None if s.edit.edit_mode == EditMode::GridRelative && s.edit.selection.is_empty() && cb.cut_from == Some(s.edit.selection.start) => {
|
||||
s.edit.selection.start
|
||||
}
|
||||
None if s.edit.edit_mode == EditMode::GridRelative => s.grid_line_at_or_before(s.edit.selection.start).saturating_add(cb.grid_offset),
|
||||
None => s.edit.selection.start,
|
||||
};
|
||||
let sh = shuffle(s) && !cb.automation_only;
|
||||
@@ -385,13 +379,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 {
|
||||
@@ -683,11 +671,16 @@ fn separate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"separated": n}))
|
||||
}
|
||||
|
||||
/// Separates selected clips at every active Grid boundary in the requested range.
|
||||
fn separate_on_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.separate_on_grid", p)?;
|
||||
let r = range_param(e, "edit.separate_on_grid", p)?;
|
||||
let s = e.session_mut();
|
||||
let lines = s.edit.grid.lines(r.start, r.end, s.sample_rate, &s.tempo, s.frame_rate, 10_000);
|
||||
let max = if s.edit.grid_clips_markers { usize::MAX } else { 10_001 };
|
||||
let lines = s.grid_lines(r.start, r.end, max);
|
||||
if lines.len() > 10_000 {
|
||||
return Err(bad("edit.separate_on_grid", "the range contains more than 10,000 grid boundaries"));
|
||||
}
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
for at in &lines {
|
||||
@@ -821,38 +814,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}`")))?,
|
||||
@@ -1001,6 +964,7 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"moved": n, "by": step}))
|
||||
}
|
||||
|
||||
/// Same-track placement at the existing start is a no-op; quantization is a separate command.
|
||||
fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
if ids.is_empty() {
|
||||
@@ -1023,17 +987,21 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let mode = s.edit.edit_mode;
|
||||
let delta = match first {
|
||||
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative),
|
||||
Some(_) if mode.is_grid() && p.get("to").is_some() && delta == 0 && dest.iter().all(|(from, to)| from == to) => 0,
|
||||
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative, &ids),
|
||||
_ => delta,
|
||||
};
|
||||
let effective_delta = first.map_or(delta, |clip| delta.max(clip.start.saturating_neg()));
|
||||
let s = e.session_mut();
|
||||
let n = if mode == EditMode::Shuffle {
|
||||
ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))?
|
||||
} else if effective_delta == 0 && dest.iter().all(|(from, to)| from == to) {
|
||||
0
|
||||
} else {
|
||||
ops::move_clips(s, &ids, delta, &dest)
|
||||
ops::move_clips(s, &ids, effective_delta, &dest)
|
||||
};
|
||||
s.edit.selected_clips = ids;
|
||||
Ok(json!({"moved": n, "by": delta}))
|
||||
Ok(json!({"moved": n, "by": if mode == EditMode::Shuffle { delta } else { effective_delta }}))
|
||||
}
|
||||
|
||||
fn trim_clip(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -45,6 +45,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.sources.clear();
|
||||
s.videos.clear();
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// A template too large to open is not saved (#892).
|
||||
crate::io::session_size(file.display(), text.len() as u64, true)?;
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?;
|
||||
} else {
|
||||
@@ -335,7 +337,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();
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
|
||||
|
||||
/// Commands for mixer controls, with live input toggles kept outside document history.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("mix.volume", "Set Volume", [], None, "{tracks?, db: -144..12 | delta_db}", has_selection, volume),
|
||||
@@ -42,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
|
||||
cmd!(noundo "mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
|
||||
e,
|
||||
p,
|
||||
"mix.record_arm",
|
||||
@@ -51,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
|tr| tr.mixer.record_arm
|
||||
)),
|
||||
cmd!("mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
|
||||
cmd!(noundo "mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
|
||||
e,
|
||||
p,
|
||||
"mix.input_monitor",
|
||||
@@ -218,6 +219,23 @@ fn toggle(e: &mut Engine, p: &Value, cmd: &str, set: fn(&mut Session, TrackId, b
|
||||
Ok(json!({"value": v}))
|
||||
}
|
||||
|
||||
/// Toggle live input flags without marking the saved document as changed.
|
||||
fn toggle_runtime(
|
||||
e: &mut Engine,
|
||||
p: &Value,
|
||||
cmd: &str,
|
||||
set: fn(&mut Session, TrackId, bool),
|
||||
get: fn(&soundcraft_model::Track) -> bool,
|
||||
) -> Result<Value> {
|
||||
let tracks = tracks_required(e, cmd, p)?;
|
||||
let s = e.runtime_session_mut();
|
||||
let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(get)));
|
||||
for t in &tracks {
|
||||
set(s, *t, v);
|
||||
}
|
||||
Ok(json!({"value": v}))
|
||||
}
|
||||
|
||||
fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "mix.volume", p)?;
|
||||
let abs = p.get("db").and_then(Value::as_f64);
|
||||
@@ -598,6 +616,7 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds.
|
||||
#[cfg(test)]
|
||||
fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
|
||||
let mut prev: Option<(i64, f32)> = None;
|
||||
for &(t, v) in env {
|
||||
@@ -612,6 +631,38 @@ fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
|
||||
prev.map_or(0.0, |(_, v)| v)
|
||||
}
|
||||
|
||||
/// Repeated lookups in one clip-gain envelope, each O(log n) instead of a walk from its start.
|
||||
/// Gives exactly what walking the points in stored order gives, sorted or not: the first point
|
||||
/// at or after `at` is the first whose running maximum position reaches `at`.
|
||||
struct EnvLookup<'a> {
|
||||
env: &'a [(i64, f32)],
|
||||
/// Largest position among `env[..=i]`.
|
||||
reach: Vec<i64>,
|
||||
}
|
||||
|
||||
impl<'a> EnvLookup<'a> {
|
||||
fn new(env: &'a [(i64, f32)]) -> Self {
|
||||
let reach = env
|
||||
.iter()
|
||||
.scan(i64::MIN, |m, &(t, _)| {
|
||||
*m = (*m).max(t);
|
||||
Some(*m)
|
||||
})
|
||||
.collect();
|
||||
EnvLookup { env, reach }
|
||||
}
|
||||
|
||||
/// Value in dB at `at` (see `env_db`).
|
||||
fn db(&self, at: i64) -> f32 {
|
||||
let i = self.reach.partition_point(|&m| m < at);
|
||||
let Some(&(t, v)) = self.env.get(i) else { return self.env.last().map_or(0.0, |&(_, v)| v) };
|
||||
match i.checked_sub(1).and_then(|p| self.env.get(p)) {
|
||||
Some(&(pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32),
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the volume `lane` into the gain envelope of clip `c` over the selection `r`.
|
||||
fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) {
|
||||
let delta = |at: i64| lane.value_at(at, base) - base;
|
||||
@@ -621,39 +672,41 @@ fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model
|
||||
return;
|
||||
}
|
||||
let rel = |at: i64| at - c.start;
|
||||
let old_env = std::mem::take(&mut c.gain_env);
|
||||
let env = EnvLookup::new(&old_env);
|
||||
let env_db = |at: i64| env.db(at);
|
||||
let rel0 = rel(sel0);
|
||||
let rel1 = rel(sel1);
|
||||
// Later writes win, so the selection anchors replace any point they land on.
|
||||
let mut pts: Vec<(i64, f32)> =
|
||||
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
|
||||
let mut pts: Vec<(i64, f32)> = old_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
|
||||
if rel0 > 0 {
|
||||
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
|
||||
pts.push((rel0 - 1, env_db(rel0 - 1)));
|
||||
}
|
||||
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
|
||||
pts.push((rel0, env_db(rel0) + delta(sel0)));
|
||||
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
|
||||
let at = rel(pt.at);
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
|
||||
pts.push((at, env_db(at) + delta(pt.at)));
|
||||
}
|
||||
// The clip's own gain points inside the selection keep their shape, offset by the lane.
|
||||
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
|
||||
for &(at, _) in old_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
|
||||
pts.push((at, env_db(at) + delta(c.start.saturating_add(at))));
|
||||
}
|
||||
if sel1 - 1 > sel0 {
|
||||
let at = rel(sel1 - 1);
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
|
||||
pts.push((at, env_db(at) + delta(sel1 - 1)));
|
||||
}
|
||||
if sel1 < c.end() {
|
||||
pts.push((rel1, env_db(&c.gain_env, rel1)));
|
||||
pts.push((rel1, env_db(rel1)));
|
||||
}
|
||||
pts.sort_by_key(|p| p.0);
|
||||
let mut env = Vec::new();
|
||||
let mut baked = Vec::new();
|
||||
for p in pts {
|
||||
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
|
||||
env.pop();
|
||||
if baked.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
|
||||
baked.pop();
|
||||
}
|
||||
env.push(p);
|
||||
baked.push(p);
|
||||
}
|
||||
c.gain_env = env;
|
||||
c.gain_env = baked;
|
||||
}
|
||||
|
||||
/// Clear the volume lane over `r` when the clips all sit inside it, else hold the fader there.
|
||||
@@ -726,8 +779,9 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if let Some(l) = &old {
|
||||
ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1));
|
||||
}
|
||||
let env = EnvLookup::new(&c.gain_env);
|
||||
for at in ats {
|
||||
pts.push((at, level(at) + c.gain_db + env_db(&c.gain_env, at - c.start)));
|
||||
pts.push((at, level(at) + c.gain_db + env.db(at - c.start)));
|
||||
}
|
||||
c.gain_db = 0.0;
|
||||
c.gain_env.clear();
|
||||
|
||||
@@ -345,3 +345,142 @@ fn insert_and_send_reject_invalid_slot() {
|
||||
let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
|
||||
assert_eq!(r["slot"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_envelope_lookup_matches_the_walk() {
|
||||
let envs: [Vec<(i64, f32)>; 5] = [
|
||||
Vec::new(),
|
||||
vec![(100, -3.0)],
|
||||
vec![(0, 0.0), (100, -6.0), (100, 3.0), (250, -12.0), (900, 1.0)],
|
||||
// Out of order, as a hand-edited session file may hold.
|
||||
vec![(500, -1.0), (100, -6.0), (700, 4.0), (300, 2.0), (700, -9.0), (50, 0.5)],
|
||||
(0..200).map(|i| ((i * 37) % 1000, (i as f32 * 0.7).sin() * 12.0)).collect(),
|
||||
];
|
||||
for env in &envs {
|
||||
let lookup = EnvLookup::new(env);
|
||||
for at in -20..1100 {
|
||||
assert_eq!(lookup.db(at).to_bits(), env_db(env, at).to_bits(), "{env:?} at {at}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_to_volume_with_a_large_envelope_stays_fast() {
|
||||
let (mut e, t) = tone_session(&[]);
|
||||
let clip = e.session().track(t).unwrap().clips()[0].id;
|
||||
let n = 200_000i64;
|
||||
let c = e.session_mut().find_clip_mut(clip).unwrap();
|
||||
c.length = n * 5 + 10;
|
||||
c.gain_env = (0..n).map(|i| (i * 5, if i % 2 == 0 { -6.0 } else { 0.0 })).collect();
|
||||
let start = std::time::Instant::now();
|
||||
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": n * 5 + 10})).unwrap();
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
|
||||
assert!(lane.points.len() as i64 >= n);
|
||||
}
|
||||
|
||||
/// Live input toggles stay outside document history in both enabled and disabled states.
|
||||
#[test]
|
||||
fn live_input_states_are_transient_and_survive_document_history() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let track = e.session().tracks[0].id;
|
||||
e.mark_clean();
|
||||
let history = e.undo_history();
|
||||
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("track.set_record_to_input_only", &json!({})).unwrap();
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(mixer.record_arm && mixer.input_monitor);
|
||||
assert!(!e.is_dirty(), "live input state is not a document change");
|
||||
assert_eq!(e.undo_history(), history, "live input state has no Undo entry");
|
||||
|
||||
// A later document edit captures a full Session snapshot. Undoing or redoing it must not
|
||||
// resurrect the live state that happened to be present when that snapshot was taken.
|
||||
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": false})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": false})).unwrap();
|
||||
assert!(e.is_dirty());
|
||||
|
||||
assert!(e.undo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(!e.is_dirty(), "undo returned to the saved document");
|
||||
|
||||
assert!(e.redo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(e.is_dirty());
|
||||
|
||||
// Live states that are enabled after another document edit survive both directions too.
|
||||
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -12.0})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
|
||||
assert!(e.undo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(mixer.record_arm && mixer.input_monitor);
|
||||
|
||||
assert!(e.redo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(mixer.record_arm && mixer.input_monitor);
|
||||
}
|
||||
|
||||
/// Returning to a saved point stays clean with independently enabled live flags on each track.
|
||||
#[test]
|
||||
fn live_input_states_keep_saved_history_points_clean() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 2})).unwrap();
|
||||
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
|
||||
e.mark_clean();
|
||||
e.execute("mix.volume", &json!({"tracks": [a.0], "db": -6.0})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
|
||||
assert!(e.undo());
|
||||
assert!(!e.is_dirty(), "live flags cannot dirty the saved point reached by Undo");
|
||||
for redo in [true, false, true] {
|
||||
assert!(if redo { e.redo() } else { e.undo() });
|
||||
let a = &e.session().track(a).unwrap().mixer;
|
||||
let b = &e.session().track(b).unwrap().mixer;
|
||||
assert!(a.record_arm && !a.input_monitor);
|
||||
assert!(!b.record_arm && b.input_monitor);
|
||||
assert_eq!(e.is_dirty(), redo);
|
||||
}
|
||||
e.mark_clean();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": false})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": false})).unwrap();
|
||||
assert!(!e.is_dirty());
|
||||
assert!(e.undo());
|
||||
assert!(e.is_dirty());
|
||||
assert!(e.redo());
|
||||
assert!(!e.is_dirty(), "Redo to a saved armed snapshot stays clean after disarming");
|
||||
}
|
||||
|
||||
/// Restoring a missing track never resurrects its historical arm or monitoring flags.
|
||||
#[test]
|
||||
fn live_input_states_of_restored_tracks_start_disabled() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 2})).unwrap();
|
||||
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
|
||||
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
|
||||
e.execute("track.delete", &json!({"tracks": [a.0]})).unwrap();
|
||||
assert!(e.undo());
|
||||
let mixer = &e.session().track(a).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(e.session().track(b).unwrap().mixer.input_monitor);
|
||||
assert!(e.redo());
|
||||
assert!(e.undo());
|
||||
assert!(!e.session().track(a).unwrap().mixer.record_arm);
|
||||
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let c = e.session().tracks.last().unwrap().id;
|
||||
e.execute("mix.record_arm", &json!({"tracks": [c.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [c.0], "value": true})).unwrap();
|
||||
assert!(e.undo());
|
||||
assert!(e.session().track(c).is_none());
|
||||
assert!(e.redo());
|
||||
let mixer = &e.session().track(c).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(e.session().track(b).unwrap().mixer.input_monitor);
|
||||
}
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -410,7 +472,7 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "file.import_session_data";
|
||||
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string();
|
||||
let text_v = crate::io::read_text(&path)?;
|
||||
let text_v = crate::io::read_session_text(&path)?;
|
||||
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let names: Option<Vec<String>> =
|
||||
p.get("tracks").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ChannelFormat, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
|
||||
|
||||
/// Returns the track command specifications exposed to UI and automation clients.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -14,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New...",
|
||||
["Track"],
|
||||
Some("Cmd+Shift+N"),
|
||||
"{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id}",
|
||||
"{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id, rows?: [objects with the same fields] (1–128 tracks total)}",
|
||||
always,
|
||||
new_track
|
||||
),
|
||||
@@ -99,8 +100,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("track.bounce", "Bounce...", ["Track"], Some("Cmd+Alt+Shift+B"), "{tracks?}", has_selection, commit),
|
||||
cmd!("track.bypass_inserts", "All", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, bypass_inserts),
|
||||
cmd!("track.mute_sends", "All", ["Track", "Mute Sends"], None, "{tracks?, mute?: bool}", has_selection, mute_sends),
|
||||
cmd!("track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
|
||||
let s = e.session_mut();
|
||||
cmd!(noundo "track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
|
||||
let s = e.runtime_session_mut();
|
||||
let mut n = 0;
|
||||
for t in &mut s.tracks {
|
||||
if t.mixer.record_arm {
|
||||
@@ -231,7 +232,37 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Creates either one legacy row or several validated rows as one command.
|
||||
fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = if let Some(rows) = p.get("rows") {
|
||||
let rows = rows.as_array().ok_or_else(|| bad("track.new", "`rows` must be an array"))?;
|
||||
if rows.is_empty() || rows.len() > 128 {
|
||||
return Err(bad("track.new", "`rows` must contain 1–128 rows"));
|
||||
}
|
||||
let mut total = 0_i64;
|
||||
for row in rows {
|
||||
if !row.is_object() || row.get("rows").is_some() {
|
||||
return Err(bad("track.new", "each row must be an object without nested rows"));
|
||||
}
|
||||
total = total.saturating_add(i64_or(row, "count", 1).clamp(1, 128));
|
||||
}
|
||||
if total > 128 {
|
||||
return Err(bad("track.new", "a single creation is limited to 128 tracks"));
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for row in rows {
|
||||
ids.extend(new_track_row(e, row)?);
|
||||
}
|
||||
ids
|
||||
} else {
|
||||
new_track_row(e, p)?
|
||||
};
|
||||
e.session_mut().edit.selected_tracks = ids.clone();
|
||||
Ok(json!({"tracks": ids}))
|
||||
}
|
||||
|
||||
/// All rows run inside one command, so failures roll back and Undo covers the whole creation.
|
||||
fn new_track_row(e: &mut Engine, p: &Value) -> Result<Vec<TrackId>> {
|
||||
let count = i64_or(p, "count", 1).clamp(1, 128);
|
||||
let kind = match str_param(p, "kind") {
|
||||
Some(k) => TrackKind::from_id(k).ok_or_else(|| bad("track.new", format!("unknown kind `{k}`")))?,
|
||||
@@ -271,8 +302,7 @@ fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
s.edit.selected_tracks = ids.clone();
|
||||
Ok(json!({"tracks": ids}))
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn group(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
@@ -590,6 +620,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 +752,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 +1177,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();
|
||||
|
||||
@@ -267,6 +267,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"path": path, "track": first}))
|
||||
}
|
||||
|
||||
/// Load persistent mixer settings while retaining destination routing and live input flags.
|
||||
fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "track.load_preset";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
|
||||
@@ -283,15 +284,17 @@ fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
// Routing is session-specific: keep the track's own input/output, VCA and arm state.
|
||||
let keep = (tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.pan.clone());
|
||||
// Routing and live input state are track-specific: a preset must not replace them.
|
||||
let keep =
|
||||
(tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.input_monitor, tr.mixer.pan.clone());
|
||||
tr.mixer = mixer.clone();
|
||||
tr.mixer.input = keep.0;
|
||||
tr.mixer.output = keep.1;
|
||||
tr.mixer.vca = keep.2;
|
||||
tr.mixer.record_arm = keep.3;
|
||||
if tr.mixer.pan.len() != keep.4.len() {
|
||||
tr.mixer.pan = keep.4;
|
||||
tr.mixer.input_monitor = keep.4;
|
||||
if tr.mixer.pan.len() != keep.5.len() {
|
||||
tr.mixer.pan = keep.5;
|
||||
}
|
||||
if tr.kind == TrackKind::Instrument && instrument.is_some() {
|
||||
tr.instrument = instrument.clone();
|
||||
@@ -602,6 +605,7 @@ mod tests {
|
||||
assert_eq!(e.session().track(pad).unwrap().folder, Some(verb));
|
||||
}
|
||||
|
||||
/// A preset transfers persistent mix settings without replacing live destination states.
|
||||
#[test]
|
||||
fn preset_round_trip() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-preset-{}", std::process::id()));
|
||||
@@ -611,10 +615,13 @@ mod tests {
|
||||
e.execute("edit.select", &json!({"tracks": ["Bass"]})).unwrap();
|
||||
e.execute("track.save_preset", &json!({"path": path})).unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": ["Hats"]})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"value": true})).unwrap();
|
||||
e.execute("track.load_preset", &json!({"path": path})).unwrap();
|
||||
let hats = e.session().track_by_name("Hats").unwrap();
|
||||
assert_eq!(hats.mixer.inserts[0].as_ref().map(|i| i.plugin.as_str()), Some("compressor"));
|
||||
assert_eq!(hats.mixer.volume_db, -5.0);
|
||||
assert!(hats.mixer.record_arm && hats.mixer.input_monitor, "loading a preset keeps live input state");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -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!({})) }),
|
||||
|
||||
+186
-14
@@ -3,9 +3,9 @@ use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_
|
||||
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_time::TimeFormat;
|
||||
use soundcraft_time::{GridValue, MAX_POSITION, NoteValue};
|
||||
use soundcraft_time::{FeetFramesValue, GridScale, GridValue, MAX_POSITION, NoteValue, TimeFormat, TimecodeValue};
|
||||
|
||||
/// Returns commands that control saved view, counter, Grid, and Nudge state.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
|
||||
@@ -65,7 +65,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
|
||||
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
|
||||
let f = str_param(p, "format").and_then(TimeFormat::from_id).ok_or_else(|| bad("view.main_counter", "unknown format"))?;
|
||||
e.session_mut().edit.main_counter = f;
|
||||
let edit = &mut e.session_mut().edit;
|
||||
edit.main_counter = f;
|
||||
let scale = grid_scale(f);
|
||||
if edit.grid_follow_main && edit.grid.scale() != scale {
|
||||
edit.grid = GridValue::for_scale(scale);
|
||||
}
|
||||
if edit.grid_follow_main {
|
||||
edit.grid_clips_markers = false;
|
||||
}
|
||||
if edit.nudge_follow_main && edit.nudge.scale() != scale {
|
||||
edit.nudge = GridValue::for_scale(scale);
|
||||
}
|
||||
Ok(json!({"format": f}))
|
||||
}),
|
||||
cmd!(noundo "view.sub_counter", "Sub Counter", [], None, "{format?: … | null}", always, |e, p| {
|
||||
@@ -73,37 +84,80 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
e.session_mut().edit.sub_counter = f;
|
||||
Ok(json!({"format": f}))
|
||||
}),
|
||||
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, or step: ±n values larger or smaller; lines?: bool}", always, |e, p| {
|
||||
let g = value_or_step(p, e.session().edit.grid).ok_or_else(|| bad("view.grid", "unknown grid value"))?;
|
||||
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples|clips_markers, follow_main?: bool, lines?: bool}", always, |e, p| {
|
||||
let current = e.session().edit.grid;
|
||||
let requested_scale = str_param(p, "format").and_then(GridScale::from_id);
|
||||
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.grid", "unknown grid value or format"))?;
|
||||
let lines = p.get("lines").and_then(Value::as_bool);
|
||||
let follow = p.get("follow_main").and_then(Value::as_bool);
|
||||
if follow == Some(true) {
|
||||
let scale = grid_scale(e.session().edit.main_counter);
|
||||
if g.scale() != scale {
|
||||
g = GridValue::for_scale(scale);
|
||||
}
|
||||
}
|
||||
let s = e.session_mut();
|
||||
s.edit.grid = g;
|
||||
if p.get("value").is_some() || p.get("step").is_some() {
|
||||
s.edit.grid_clips_markers = false;
|
||||
}
|
||||
if let Some(scale) = requested_scale {
|
||||
s.edit.grid_clips_markers = scale == GridScale::ClipsMarkers;
|
||||
}
|
||||
if follow == Some(true) {
|
||||
s.edit.grid_clips_markers = false;
|
||||
}
|
||||
s.edit.grid_follow_main = follow.unwrap_or(
|
||||
s.edit.grid_follow_main && !s.edit.grid_clips_markers && g.scale() == grid_scale(s.edit.main_counter),
|
||||
);
|
||||
if let Some(l) = lines { s.edit.grid_lines = l }
|
||||
Ok(json!({"grid": g.label()}))
|
||||
let format = if s.edit.grid_clips_markers { GridScale::ClipsMarkers } else { g.scale() };
|
||||
Ok(json!({"grid": g.label(), "format": format.id(), "follow_main": s.edit.grid_follow_main}))
|
||||
}),
|
||||
cmd!(noundo "view.grid_lines", "Show Grid Lines", [], None, "{value?: bool}", always, |e, p| {
|
||||
let s = e.session_mut();
|
||||
s.edit.grid_lines = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.grid_lines);
|
||||
Ok(json!({"value": s.edit.grid_lines}))
|
||||
}),
|
||||
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value or step: like grid}", always, |e, p| {
|
||||
let g = value_or_step(p, e.session().edit.nudge).ok_or_else(|| bad("view.nudge", "unknown nudge value"))?;
|
||||
e.session_mut().edit.nudge = g;
|
||||
Ok(json!({"nudge": g.label()}))
|
||||
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples, follow_main?: bool}", always, |e, p| {
|
||||
let current = e.session().edit.nudge;
|
||||
if str_param(p, "format").and_then(GridScale::from_id) == Some(GridScale::ClipsMarkers) {
|
||||
return Err(bad("view.nudge", "Clips/Markers is available only for Grid"));
|
||||
}
|
||||
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.nudge", "unknown nudge value or format"))?;
|
||||
let follow = p.get("follow_main").and_then(Value::as_bool);
|
||||
if follow == Some(true) {
|
||||
let scale = grid_scale(e.session().edit.main_counter);
|
||||
if g.scale() != scale {
|
||||
g = GridValue::for_scale(scale);
|
||||
}
|
||||
}
|
||||
let edit = &mut e.session_mut().edit;
|
||||
edit.nudge = g;
|
||||
edit.nudge_follow_main = follow.unwrap_or(edit.nudge_follow_main && g.scale() == grid_scale(edit.main_counter));
|
||||
Ok(json!({"nudge": g.label(), "format": g.scale().id(), "follow_main": edit.nudge_follow_main}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
const MIN_SPP: f64 = 0.25;
|
||||
const MAX_SPP: f64 = 1_000_000.0;
|
||||
/// Pixel column an off-screen edit cursor is brought to when zooming (the engine does not know the viewport width).
|
||||
const OFFSCREEN_CURSOR_PX: f64 = 500.0;
|
||||
|
||||
fn zoom(e: &mut Engine, f: f64, x: Option<f64>) -> Result<Value> {
|
||||
let insertion = e.session().edit.selection.start as f64;
|
||||
let z = e.zoom_mut();
|
||||
let (old, scroll) = (z.samples_per_px, z.scroll as f64);
|
||||
let new = (old * f).clamp(MIN_SPP, MAX_SPP);
|
||||
if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) {
|
||||
// Without a pointer, the edit cursor is the reference: held in place when visible, centred when off-screen.
|
||||
let cursor_x = (insertion - scroll) / old;
|
||||
if let Some(x) = x {
|
||||
z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION);
|
||||
} else if (0.0..4000.0).contains(&cursor_x) {
|
||||
z.scroll = soundcraft_time::to_samples(scroll + cursor_x * (old - new)).clamp(0, MAX_POSITION);
|
||||
} else {
|
||||
z.scroll = soundcraft_time::to_samples(insertion - OFFSCREEN_CURSOR_PX * new).clamp(0, MAX_POSITION);
|
||||
}
|
||||
z.samples_per_px = new;
|
||||
Ok(json!({"samples_per_px": new, "scroll": z.scroll}))
|
||||
@@ -129,7 +183,33 @@ fn value_or_step(p: &Value, current: GridValue) -> Option<GridValue> {
|
||||
Some((0..step.unsigned_abs().min(16)).fold(current, |g, _| g.stepped(step > 0)))
|
||||
}
|
||||
|
||||
/// Parse grid/nudge values.
|
||||
/// Maps a counter format to the corresponding Grid/Nudge scale.
|
||||
fn grid_scale(format: TimeFormat) -> GridScale {
|
||||
match format {
|
||||
TimeFormat::BarsBeats => GridScale::BarsBeats,
|
||||
TimeFormat::MinSecs => GridScale::MinSecs,
|
||||
TimeFormat::Timecode => GridScale::Timecode,
|
||||
TimeFormat::FeetFrames => GridScale::FeetFrames,
|
||||
TimeFormat::Samples => GridScale::Samples,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an explicit value, a step, or a scale change while preserving the current increment.
|
||||
fn requested_grid_value(p: &Value, current: GridValue) -> Option<GridValue> {
|
||||
if p.get("value").is_some() || p.get("step").is_some() {
|
||||
return value_or_step(p, current);
|
||||
}
|
||||
let scale = str_param(p, "format").and_then(GridScale::from_id);
|
||||
match scale {
|
||||
Some(GridScale::ClipsMarkers) => Some(current),
|
||||
Some(scale) if current.scale() == scale => Some(current),
|
||||
Some(scale) => Some(GridValue::for_scale(scale)),
|
||||
None if p.get("follow_main").is_some() || p.get("lines").is_some() => Some(current),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse grid/nudge values; legacy `1` and `1/1` mean a bar, while `whole` means four quarter notes.
|
||||
pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
let v = p.get(key)?;
|
||||
if let Some(sec) = v.get("seconds").and_then(Value::as_f64) {
|
||||
@@ -138,15 +218,23 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
if let Some(f) = v.get("frames").and_then(Value::as_i64) {
|
||||
return Some(GridValue::Frames(f.clamp(1, 100_000)));
|
||||
}
|
||||
if let Some(value) = v.get("timecode").and_then(Value::as_str).and_then(TimecodeValue::from_id) {
|
||||
return Some(GridValue::Timecode(value));
|
||||
}
|
||||
if let Some(value) = v.get("feet_frames").and_then(Value::as_str).and_then(FeetFramesValue::from_id) {
|
||||
return Some(GridValue::FeetFrames(value));
|
||||
}
|
||||
if let Some(n) = v.get("samples").and_then(Value::as_i64) {
|
||||
return Some(GridValue::Samples(n.clamp(1, i64::from(u32::MAX))));
|
||||
}
|
||||
let s = v.as_str()?.trim().to_ascii_lowercase();
|
||||
let triplet = s.ends_with('t') || s.contains("triplet");
|
||||
let dotted = s.ends_with('.') || s.contains("dotted");
|
||||
let core: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '/' || *c == ' ' || c.is_ascii_alphabetic() && *c != 't').collect();
|
||||
let core = s.replace("triplet", "").replace("dotted", "");
|
||||
let core = core.trim().trim_end_matches(['t', '.']);
|
||||
let value = match core.trim() {
|
||||
"1 bar" | "bar" | "1/1" | "1" => NoteValue::Bar,
|
||||
"1" | "1/1" | "1 bar" | "bar" => NoteValue::Bar,
|
||||
"whole" | "whole note" => NoteValue::Whole,
|
||||
"1/2" => NoteValue::Half,
|
||||
"1/4" => NoteValue::Quarter,
|
||||
"1/8" => NoteValue::Eighth,
|
||||
@@ -162,15 +250,59 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_time::Samples;
|
||||
/// Grid values accept compact and word-form musical modifiers plus every absolute scale.
|
||||
#[test]
|
||||
fn grid_values_parse() {
|
||||
assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/8t"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/8 triplet"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/4."}), "v"), Some(GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }));
|
||||
for alias in ["1", "1/1"] {
|
||||
assert_eq!(grid_param(&json!({"v": alias}), "v"), Some(GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }));
|
||||
}
|
||||
assert_eq!(grid_param(&json!({"v": "whole"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": "whole note"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
|
||||
assert_eq!(grid_param(&json!({"v": {"timecode": "quarter_frame"}}), "v"), Some(GridValue::Timecode(TimecodeValue::QuarterFrame)));
|
||||
assert_eq!(grid_param(&json!({"v": {"feet_frames": "foot"}}), "v"), Some(GridValue::FeetFrames(FeetFramesValue::Foot)));
|
||||
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
|
||||
}
|
||||
|
||||
/// Explicit values and formats retain Follow Main only when the selected scale still matches.
|
||||
#[test]
|
||||
fn explicit_grid_and_nudge_scales_keep_follow_state_consistent() {
|
||||
for command in ["view.grid", "view.nudge"] {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.main_counter", &json!({"format": "min_secs"})).unwrap();
|
||||
e.execute(command, &json!({"follow_main": true})).unwrap();
|
||||
let result = e.execute(command, &json!({"value": {"seconds": 0.1}})).unwrap();
|
||||
assert_eq!(result["follow_main"], true);
|
||||
let result = e.execute(command, &json!({"value": {"feet_frames": "foot"}})).unwrap();
|
||||
assert_eq!(result["follow_main"], false);
|
||||
e.execute(command, &json!({"follow_main": true})).unwrap();
|
||||
let result = e.execute(command, &json!({"format": "bars_beats"})).unwrap();
|
||||
assert_eq!(result["follow_main"], false);
|
||||
}
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.grid", &json!({"format": "clips_markers"})).unwrap();
|
||||
assert!(!e.session().edit.grid_follow_main);
|
||||
e.execute("view.grid", &json!({"value": {"samples": 100}})).unwrap();
|
||||
assert!(!e.session().edit.grid_clips_markers);
|
||||
assert_eq!(e.session().edit.grid, GridValue::Samples(100));
|
||||
}
|
||||
|
||||
/// Historical bar aliases retain meter-dependent duration; the explicit whole note remains distinct.
|
||||
#[test]
|
||||
fn legacy_bar_aliases_and_whole_notes_remain_distinct_in_odd_meter() {
|
||||
let mut e = Engine::default();
|
||||
e.session_mut().tempo = soundcraft_time::TempoMap::new(120.0, 3, 4).unwrap();
|
||||
for (value, expected) in [("1", 72_000), ("1/1", 72_000), ("whole", 96_000)] {
|
||||
e.execute("view.grid", &json!({"value": value, "follow_main": false})).unwrap();
|
||||
let s = e.session();
|
||||
assert_eq!(s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate), expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn view(e: &Engine) -> (f64, Samples) {
|
||||
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
|
||||
}
|
||||
@@ -186,6 +318,17 @@ mod tests {
|
||||
assert_eq!(view(&e), (100.0, 48_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_in_brings_an_offscreen_edit_cursor_into_view() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap();
|
||||
e.execute("edit.select", &json!({"start": 1_000_000, "end": 1_000_000})).unwrap();
|
||||
e.execute("view.zoom_in", &json!({})).unwrap();
|
||||
let (spp, scroll) = view(&e);
|
||||
let x = (1_000_000.0 - scroll as f64) / spp;
|
||||
assert!((x - OFFSCREEN_CURSOR_PX).abs() < 1.0, "cursor at {x}px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_and_scroll_stay_in_range() {
|
||||
let mut e = Engine::default();
|
||||
@@ -222,6 +365,7 @@ mod tests {
|
||||
assert_eq!(state(&e), before);
|
||||
}
|
||||
|
||||
/// Grid and Nudge steps drive edit movement while preserving their selected increments.
|
||||
#[test]
|
||||
fn grid_and_nudge_values_step_and_nudges_can_use_the_next_value() {
|
||||
let mut e = Engine::default();
|
||||
@@ -243,4 +387,32 @@ mod tests {
|
||||
assert_eq!(e.session().edit.selection.start, 6_000);
|
||||
assert_eq!(e.session().edit.nudge.label(), "1/16 note");
|
||||
}
|
||||
|
||||
/// Grid and Nudge expose independent scale-following state, while Clips/Markers remains Grid-only.
|
||||
#[test]
|
||||
fn grid_and_nudge_choose_scales_follow_the_main_counter_and_keep_clips_markers_grid_only() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.grid", &json!({"format": "timecode", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::Frame));
|
||||
e.execute("view.grid", &json!({"value": {"timecode": "quarter_frame"}})).unwrap();
|
||||
e.execute("view.grid", &json!({"format": "clips_markers", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::QuarterFrame));
|
||||
assert!(e.session().edit.grid_clips_markers);
|
||||
assert!(e.execute("view.nudge", &json!({"format": "clips_markers"})).is_err());
|
||||
|
||||
e.execute("view.grid", &json!({"follow_main": true})).unwrap();
|
||||
e.execute("view.nudge", &json!({"follow_main": true})).unwrap();
|
||||
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Seconds(1.0), GridValue::Seconds(1.0)));
|
||||
assert!(!e.session().edit.grid_clips_markers);
|
||||
e.execute("view.main_counter", &json!({"format": "samples"})).unwrap();
|
||||
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Samples(100), GridValue::Samples(100)));
|
||||
assert!(e.session().edit.grid_follow_main && e.session().edit.nudge_follow_main);
|
||||
|
||||
e.execute("view.grid", &json!({"format": "bars_beats", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::default());
|
||||
assert!(!e.session().edit.grid_follow_main);
|
||||
e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::default());
|
||||
assert_eq!(e.session().edit.nudge, GridValue::Timecode(TimecodeValue::Frame));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,386 @@
|
||||
//! Borrowed document equality for the saved-state indicator.
|
||||
|
||||
use soundcraft_model::{EditState, Mixer, Session, Track};
|
||||
|
||||
/// Equal apart from navigation and live input flags, without copying document data.
|
||||
pub(super) fn same_document(saved: &Session, doc: &Session) -> bool {
|
||||
document_values(saved, &saved.edit) == document_values(doc, &saved.edit)
|
||||
&& saved.tracks.len() == doc.tracks.len()
|
||||
&& saved.tracks.iter().zip(&doc.tracks).all(|(saved, doc)| track_values(saved) == track_values(doc))
|
||||
}
|
||||
|
||||
/// Borrow persistent session fields; track fields are compared separately.
|
||||
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, sources, videos, busses),
|
||||
(outputs, markers, groups, auto_names, edit_values(edit, navigation), next_id, comments, midi_originals, pool, folder),
|
||||
)
|
||||
}
|
||||
|
||||
/// Borrow persistent track fields without allocating or copying media.
|
||||
fn track_values(track: &Track) -> impl PartialEq + '_ {
|
||||
let Track {
|
||||
id,
|
||||
name,
|
||||
kind,
|
||||
format,
|
||||
color,
|
||||
height,
|
||||
playlists,
|
||||
active_playlist,
|
||||
mixer,
|
||||
automation,
|
||||
view,
|
||||
hidden,
|
||||
inactive,
|
||||
frozen,
|
||||
comments,
|
||||
folder,
|
||||
folder_open,
|
||||
instrument,
|
||||
ticks_timebase,
|
||||
elastic,
|
||||
pinned,
|
||||
} = track;
|
||||
(
|
||||
(id, name, kind, format, color, height, playlists, active_playlist, mixer_values(mixer), automation, view),
|
||||
(hidden, inactive, frozen, comments, folder, folder_open, instrument, ticks_timebase, elastic, pinned),
|
||||
)
|
||||
}
|
||||
|
||||
/// Borrow persistent mixer fields without allocating or copying media.
|
||||
fn mixer_values(mixer: &Mixer) -> impl PartialEq + '_ {
|
||||
let Mixer {
|
||||
volume_db,
|
||||
pan,
|
||||
mute,
|
||||
solo,
|
||||
solo_safe,
|
||||
record_arm: _,
|
||||
input_monitor: _,
|
||||
phase_invert,
|
||||
input,
|
||||
output,
|
||||
inserts,
|
||||
sends,
|
||||
automation_mode,
|
||||
trim_db,
|
||||
vca,
|
||||
surround,
|
||||
} = mixer;
|
||||
((volume_db, pan, mute, solo, solo_safe, phase_invert, input, output, inserts, sends, automation_mode), (trim_db, vca, surround))
|
||||
}
|
||||
|
||||
/// Borrow edit settings while normalizing transient navigation to the saved values.
|
||||
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,
|
||||
grid_follow_main,
|
||||
nudge_follow_main,
|
||||
grid_clips_markers,
|
||||
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,
|
||||
),
|
||||
(
|
||||
grid_follow_main,
|
||||
nudge_follow_main,
|
||||
grid_clips_markers,
|
||||
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 clone-based policy, extended to ignore live input flags, serves as a test oracle.
|
||||
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;
|
||||
for (saved, doc) in saved.tracks.iter().zip(&mut doc.tracks) {
|
||||
doc.mixer.record_arm = saved.mixer.record_arm;
|
||||
doc.mixer.input_monitor = saved.mixer.input_monitor;
|
||||
}
|
||||
*saved == doc
|
||||
}
|
||||
|
||||
/// Check both directions and compare against the normalized clone oracle.
|
||||
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),
|
||||
("grid_follow_main", |e| e.grid_follow_main = !e.grid_follow_main),
|
||||
("nudge_follow_main", |e| e.nudge_follow_main = !e.nudge_follow_main),
|
||||
("grid_clips_markers", |e| e.grid_clips_markers = !e.grid_clips_markers),
|
||||
("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");
|
||||
}
|
||||
|
||||
/// Only arm and monitoring are transient; every other mixer field still contributes to dirtiness.
|
||||
#[test]
|
||||
fn live_flags_do_not_hide_persistent_mixer_changes() {
|
||||
use soundcraft_model::{AutomationMode, BusId, Insert, Route, SendSlot, SurroundPan};
|
||||
let mut saved = Session::default();
|
||||
let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let mut live = saved.clone();
|
||||
live.track_mut(track).unwrap().mixer.record_arm = true;
|
||||
live.track_mut(track).unwrap().mixer.input_monitor = true;
|
||||
check(&saved, &live, true, "live input flags");
|
||||
let edits: &[(&str, fn(&mut Mixer))] = &[
|
||||
("volume", |m| m.volume_db = -6.0),
|
||||
("pan", |m| m.pan[0] = 0.5),
|
||||
("mute", |m| m.mute = true),
|
||||
("solo", |m| m.solo = true),
|
||||
("solo_safe", |m| m.solo_safe = true),
|
||||
("phase_invert", |m| m.phase_invert = true),
|
||||
("input", |m| m.input = Route::Hardware("Mic".into())),
|
||||
("output", |m| m.output = Route::Bus(BusId(900))),
|
||||
("inserts", |m| m.inserts[0] = Some(Insert::new("eq7"))),
|
||||
("sends", |m| m.sends[0] = Some(SendSlot::new(Route::Main))),
|
||||
("automation_mode", |m| m.automation_mode = AutomationMode::Touch),
|
||||
("trim_db", |m| m.trim_db = -3.0),
|
||||
("vca", |m| m.vca = Some(900)),
|
||||
("surround", |m| m.surround = Some(SurroundPan::default())),
|
||||
];
|
||||
for &(field, edit) in edits {
|
||||
let mut doc = live.clone();
|
||||
edit(&mut doc.track_mut(track).unwrap().mixer);
|
||||
check(&saved, &doc, false, field);
|
||||
}
|
||||
let mut doc = live.clone();
|
||||
doc.tracks.push(doc.tracks[0].clone());
|
||||
check(&saved, &doc, false, "track count");
|
||||
let mut doc = live.clone();
|
||||
doc.tracks[0].id = TrackId(900);
|
||||
check(&saved, &doc, false, "track identity");
|
||||
let mut doc = live.clone();
|
||||
doc.tracks[0].mixer.volume_db = f32::NAN;
|
||||
assert!(!same_document(&doc, &doc), "persistent NaN keeps PartialEq semantics");
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
+41
-23
@@ -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)
|
||||
}
|
||||
|
||||
@@ -505,9 +528,15 @@ fn join_gain_env(l: &mut Clip, r: &Clip) {
|
||||
}
|
||||
|
||||
/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them
|
||||
/// elsewhere (see [`destinations`]).
|
||||
/// elsewhere (see [`destinations`]). Same-track moves clamped to zero leave all material intact.
|
||||
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize {
|
||||
let ids: HashSet<ClipId> = ids.iter().copied().collect();
|
||||
// Limit the shift before removing any clips, so a stationary edit cannot overwrite overlaps.
|
||||
let limit = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| ids.contains(&c.id) && !locked(c)).map(|c| c.start.saturating_neg()).max();
|
||||
let delta = delta.max(limit.unwrap_or(0));
|
||||
if delta == 0 && dest.iter().all(|(from, to)| from == to) {
|
||||
return 0;
|
||||
}
|
||||
let mut moving: Vec<(TrackId, Clip)> = Vec::new();
|
||||
for t in &mut s.tracks {
|
||||
let tid = t.id;
|
||||
@@ -524,7 +553,7 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
|
||||
}
|
||||
let n = moving.len();
|
||||
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());
|
||||
@@ -547,20 +576,7 @@ fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) {
|
||||
|
||||
/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder at any level.
|
||||
pub fn shown(s: &Session, t: &Track) -> bool {
|
||||
!t.hidden && !folder_collapsed(s, t)
|
||||
}
|
||||
|
||||
/// Hidden because a folder at any level above this track is closed.
|
||||
fn folder_collapsed(s: &Session, track: &Track) -> bool {
|
||||
let mut current = track.folder;
|
||||
for _ in 0..64 {
|
||||
let Some(folder) = current.and_then(|folder| s.track(folder)) else { return false };
|
||||
if !folder.folder_open {
|
||||
return true;
|
||||
}
|
||||
current = folder.folder;
|
||||
}
|
||||
false
|
||||
s.track_shown(t)
|
||||
}
|
||||
|
||||
/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance
|
||||
@@ -765,12 +781,11 @@ pub fn shuffle_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(T
|
||||
/// How far a Grabber drag by `raw` moves `clip` in Grid mode: Absolute puts its sync point (its
|
||||
/// start when it has none inside the clip) on the nearest grid line; Relative moves it by whole
|
||||
/// grid steps, keeping its offset from the grid.
|
||||
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool) -> Samples {
|
||||
let (g, sr, map, rate) = (&s.edit.grid, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool, moving: &[ClipId]) -> Samples {
|
||||
let sync = if (0..clip.length).contains(&clip.sync_point) { clip.sync_point } else { 0 };
|
||||
let anchor = clip.start.saturating_add(sync);
|
||||
let from = if relative { g.line_at_or_before(anchor, sr, map, rate) } else { anchor };
|
||||
g.snap(from.saturating_add(raw), sr, map, rate).saturating_sub(from)
|
||||
let from = if relative { s.grid_line_at_or_before_excluding(anchor, moving) } else { anchor };
|
||||
s.snap_to_grid_excluding(from.saturating_add(raw), moving).saturating_sub(from)
|
||||
}
|
||||
|
||||
/// Trim a clip's edges to `start` and/or `end` like the Trim tool, never past its file. In
|
||||
@@ -794,8 +809,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();
|
||||
@@ -296,9 +330,9 @@ fn grid_moves_snap_the_clip_start_or_keep_its_offset() {
|
||||
let (s, t) = session_with_clip();
|
||||
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
let c = s.track(t).unwrap().clips()[0].clone();
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step);
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000);
|
||||
assert_eq!(grid_delta(&s, &c, step / 3, true), 0);
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false, &[c.id]), step);
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true, &[c.id]), step + 1000);
|
||||
assert_eq!(grid_delta(&s, &c, step / 3, true, &[c.id]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -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();
|
||||
|
||||
+173
-18
@@ -42,19 +42,122 @@ pub use strip::{StripMode, StripNames, StripScan, bounded_to_track, name_strippe
|
||||
/// one. Only a regular file qualifies: a device or a pipe named by a session or a command would
|
||||
/// never end or never answer. Errors name the file.
|
||||
pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
read_file_within(path.as_ref(), false)
|
||||
}
|
||||
|
||||
/// [`read_file`]; a `session` file is refused ([`session_size`]) past [`session_limit`].
|
||||
fn read_file_within(path: &Path, session: bool) -> Result<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
crate::file_access::check_read(path).map_err(EngineError::Io)?;
|
||||
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
|
||||
if !std::fs::metadata(path).map_err(err)?.is_file() {
|
||||
let meta = std::fs::metadata(path).map_err(err)?;
|
||||
if !meta.is_file() {
|
||||
return Err(EngineError::Io(format!("{}: not a regular file", path.display())));
|
||||
}
|
||||
std::fs::read(path).map_err(err)
|
||||
if !session {
|
||||
return std::fs::read(path).map_err(err);
|
||||
}
|
||||
session_size(path.display(), meta.len(), false)?;
|
||||
// The file may have grown since: never read more than one byte past the limit.
|
||||
let mut data = Vec::new();
|
||||
std::fs::File::open(path).map_err(err)?.take(session_limit().saturating_add(1)).read_to_end(&mut data).map_err(err)?;
|
||||
session_size(path.display(), data.len() as u64, false)?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// [`read_file`] as UTF-8 text.
|
||||
pub fn read_text(path: impl AsRef<Path>) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
String::from_utf8(read_file(path)?).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
|
||||
utf8(path, read_file(path)?)
|
||||
}
|
||||
|
||||
fn utf8(path: &Path, bytes: Vec<u8>) -> Result<String> {
|
||||
String::from_utf8(bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
|
||||
}
|
||||
|
||||
/// The largest session (`.scraft`) SoundCraft opens or saves. A session is read whole and parsed
|
||||
/// in one go, so a larger one is refused by its size before it is read, and never written (it
|
||||
/// could not be opened again); real sessions are far smaller.
|
||||
pub const MAX_SESSION_BYTES: u64 = 256 << 20;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A smaller limit than [`MAX_SESSION_BYTES`] for the tests on this thread ([`SessionLimit`]).
|
||||
static TEST_SESSION_LIMIT: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// [`MAX_SESSION_BYTES`] (in tests, the limit a [`SessionLimit`] set on this thread).
|
||||
pub(crate) fn session_limit() -> u64 {
|
||||
#[cfg(test)]
|
||||
if let Some(limit) = TEST_SESSION_LIMIT.with(std::cell::Cell::get) {
|
||||
return limit;
|
||||
}
|
||||
MAX_SESSION_BYTES
|
||||
}
|
||||
|
||||
/// While it lives, sessions on this thread are opened and saved up to the limit given to
|
||||
/// [`SessionLimit::set`] instead of [`MAX_SESSION_BYTES`], so tests reach it with small sessions.
|
||||
#[cfg(test)]
|
||||
pub(crate) struct SessionLimit;
|
||||
|
||||
#[cfg(test)]
|
||||
impl SessionLimit {
|
||||
pub(crate) fn set(limit: u64) -> SessionLimit {
|
||||
TEST_SESSION_LIMIT.with(|l| l.set(Some(limit)));
|
||||
SessionLimit
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for SessionLimit {
|
||||
fn drop(&mut self) {
|
||||
TEST_SESSION_LIMIT.with(|l| l.set(None));
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses the session `name` of `len` bytes when it is larger than [`session_limit`]: too large
|
||||
/// to open, or, when `saving`, to save.
|
||||
pub(crate) fn session_size(name: impl std::fmt::Display, len: u64, saving: bool) -> Result<()> {
|
||||
let limit = session_limit();
|
||||
if len <= limit {
|
||||
return Ok(());
|
||||
}
|
||||
let most = if limit >= 1 << 20 { format!("{} MiB", limit >> 20) } else { format!("{limit} bytes") };
|
||||
Err(EngineError::Io(if saving {
|
||||
format!("{name} is too large to save: over {most}, more than SoundCraft opens")
|
||||
} else {
|
||||
format!("{name} is too large to open (over {most})")
|
||||
}))
|
||||
}
|
||||
|
||||
/// [`read_text`] for a session file: refused, unread, past [`session_limit`].
|
||||
pub(crate) fn read_session_text(path: impl AsRef<Path>) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
utf8(path, read_file_within(path, true)?)
|
||||
}
|
||||
|
||||
/// A bare `.scraft` handed over as bytes (the browser) as text: refused, uncopied, past
|
||||
/// [`session_limit`].
|
||||
pub(crate) fn session_bytes_text(name: &str, bytes: &[u8]) -> Result<String> {
|
||||
session_size(name, bytes.len() as u64, false)?;
|
||||
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))
|
||||
}
|
||||
|
||||
/// How many bytes [`Session::to_json`] makes of `s`, counted without keeping them.
|
||||
fn json_len(s: &Session) -> u64 {
|
||||
struct Count(u64);
|
||||
impl std::io::Write for Count {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0 = self.0.saturating_add(buf.len() as u64);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut n = Count(0);
|
||||
// A session that cannot be written counts as nothing here: `to_json` reports that error.
|
||||
serde_json::to_writer_pretty(&mut n, s).map_or(0, |()| n.0)
|
||||
}
|
||||
|
||||
/// Is there a file at `path` the automation read root lets us see? A path outside the root is
|
||||
@@ -178,6 +281,13 @@ pub struct Bounce {
|
||||
enc: Option<soundcraft_audio_io::FileEncoder<Out>>,
|
||||
}
|
||||
|
||||
/// The Broadcast Wave stamp of timeline position `at`: the session's timecode start plus `at`, in
|
||||
/// samples since midnight (`bext` TimeReference; 0 before midnight).
|
||||
fn time_stamp(s: &Session, at: Samples) -> soundcraft_audio_io::BwfInfo {
|
||||
let time_reference = u64::try_from(s.timecode_start.saturating_add(at)).unwrap_or(0);
|
||||
soundcraft_audio_io::BwfInfo { time_reference, ..Default::default() }
|
||||
}
|
||||
|
||||
/// A finished bounce: the size of its file and its levels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Bounced {
|
||||
@@ -208,6 +318,9 @@ impl Bounce {
|
||||
let part = path.with_file_name(format!("{name}.part"));
|
||||
crate::file_access::check_write(path).map_err(EngineError::Io)?;
|
||||
crate::file_access::check_write(&part).map_err(EngineError::Io)?;
|
||||
// A WAV bounce says where it starts on the session's timecode, as other DAWs' bounces do,
|
||||
// so post and broadcast tools can place it (only WAV has a `bext` chunk to say it in).
|
||||
let opts = &EncodeOptions { bwf: opts.bwf.clone().or_else(|| Some(time_stamp(&s, r.start))), ..opts.clone() };
|
||||
let enc = (|| -> std::result::Result<_, soundcraft_audio_io::AudioError> {
|
||||
let enc = soundcraft_audio_io::FileEncoder::new(Out::create(&part)?, opts, channels, s.sample_rate.hz(), Some(fmt.channel_mask()))?;
|
||||
enc.check_room(u64::try_from(r.len()).unwrap_or(0))?;
|
||||
@@ -487,7 +600,6 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
let mut s = session.clone();
|
||||
let from = s.folder.clone();
|
||||
let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir));
|
||||
let mut written = 0;
|
||||
// A file can stay put for all the sources that name it, unless their audio differs.
|
||||
let shared_paths: Vec<bool> = {
|
||||
let mut audio_by_path: HashMap<&str, HashSet<Option<*const SourceAudio>>> = HashMap::new();
|
||||
@@ -496,8 +608,11 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
}
|
||||
s.sources.iter().map(|src| audio_by_path.get(src.path.as_str()).is_some_and(|a| a.len() > 1)).collect()
|
||||
};
|
||||
// Files written for audio during this save: sources playing the same audio share one.
|
||||
// Files for audio in this save: sources playing the same audio share one. The files still to
|
||||
// write (their stem, audio and the sources naming them) are written once the size is checked.
|
||||
let mut files: HashMap<*const SourceAudio, String> = HashMap::new();
|
||||
let mut queued: HashMap<*const SourceAudio, usize> = HashMap::new();
|
||||
let mut pending: Vec<(String, Arc<SourceAudio>, Vec<usize>)> = Vec::new();
|
||||
for (i, src) in s.sources.iter_mut().enumerate() {
|
||||
if !same_dir
|
||||
&& s.pool.get(src.id).is_none()
|
||||
@@ -523,7 +638,14 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
src.format = FileFormat::Wav;
|
||||
continue;
|
||||
}
|
||||
make_dir(&audio_dir)?;
|
||||
if let Some((stem, _, sources)) = queued.get(&Arc::as_ptr(audio)).and_then(|&k| pending.get_mut(k)) {
|
||||
// As long as any name the file can get, so the size checked is an upper bound.
|
||||
src.path = format!("Audio Files/{stem}_9999.wav");
|
||||
src.unsaved = false;
|
||||
src.format = FileFormat::Wav;
|
||||
sources.push(i);
|
||||
continue;
|
||||
}
|
||||
let mut stem = sanitize_name(&src.name);
|
||||
if overwrite {
|
||||
// One file per source id so autosaves never collide or pile up.
|
||||
@@ -535,23 +657,46 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let (name, new) = store_wav(&audio_dir, &stem, &bytes)?;
|
||||
src.path = format!("Audio Files/{name}");
|
||||
src.path = format!("Audio Files/{stem}_9999.wav");
|
||||
src.unsaved = false;
|
||||
src.format = FileFormat::Wav;
|
||||
files.insert(Arc::as_ptr(audio), src.path.clone());
|
||||
written += usize::from(new);
|
||||
}
|
||||
if written > 0 {
|
||||
sync_dir(&audio_dir);
|
||||
queued.insert(Arc::as_ptr(audio), pending.len());
|
||||
pending.push((stem, Arc::clone(audio), vec![i]));
|
||||
}
|
||||
s.folder = Some(dir.clone());
|
||||
if !overwrite && let Some(stem) = p.file_stem().and_then(OsStr::to_str) {
|
||||
s.name = stem.to_string();
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// Before any audio is written: a session too large to open again is not saved (#892). With no
|
||||
// audio to write, the text checked is the text saved.
|
||||
let checked = if pending.is_empty() {
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
session_size(p.display(), text.len() as u64, true)?;
|
||||
Some(text)
|
||||
} else {
|
||||
session_size(p.display(), json_len(&s), true)?;
|
||||
None
|
||||
};
|
||||
let mut written = 0;
|
||||
for (stem, audio, sources) in &pending {
|
||||
make_dir(&audio_dir)?;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let (name, new) = store_wav(&audio_dir, stem, &bytes)?;
|
||||
for &i in sources {
|
||||
if let Some(src) = s.sources.get_mut(i) {
|
||||
src.path = format!("Audio Files/{name}");
|
||||
}
|
||||
}
|
||||
written += usize::from(new);
|
||||
}
|
||||
if written > 0 {
|
||||
sync_dir(&audio_dir);
|
||||
}
|
||||
let text = match checked {
|
||||
Some(text) => text,
|
||||
None => s.to_json().map_err(|err| EngineError::Io(err.to_string()))?,
|
||||
};
|
||||
make_dir(&dir)?;
|
||||
// Atomic-ish write: temp file then rename.
|
||||
let tmp = p.with_extension("scraft.tmp");
|
||||
@@ -598,6 +743,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);
|
||||
@@ -733,6 +879,12 @@ fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::colle
|
||||
if z.len() > limits.entries {
|
||||
return Err(EngineError::Io("bundle: too many files".into()));
|
||||
}
|
||||
// Entries are stored, so the files we keep can only outgrow the bundle when several entries
|
||||
// point at the same bytes: a small bundle naming one payload thousands of times would be
|
||||
// copied out once per name.
|
||||
if z.has_overlapping_files().map_err(zerr)? {
|
||||
return Err(EngineError::Io("bundle: files share data".into()));
|
||||
}
|
||||
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).map_err(zerr)?;
|
||||
@@ -745,10 +897,13 @@ fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::colle
|
||||
if total > limits.bytes {
|
||||
return Err(EngineError::Io("bundle: too large".into()));
|
||||
}
|
||||
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
|
||||
if is_session && text.is_none() {
|
||||
session_size(format_args!("bundle: {rel}"), size, false)?;
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
// The declared size is hostile too: never read past it.
|
||||
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
|
||||
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
|
||||
if is_session && text.is_none() {
|
||||
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
|
||||
} else {
|
||||
|
||||
@@ -69,6 +69,8 @@ fn session_bundle_within(session: &Session, limits: BundleLimits) -> Result<(Vec
|
||||
src.format = FileFormat::Wav;
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// Its session too: one the reader would refuse is not saved (#892).
|
||||
super::session_size("the session", text.len() as u64, true)?;
|
||||
fits(text.len())?;
|
||||
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
|
||||
w.write_all(text.as_bytes()).map_err(ioerr)?;
|
||||
@@ -100,10 +102,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
|
||||
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
|
||||
read_bundle(bytes, BUNDLE_LIMITS)?
|
||||
} else {
|
||||
(
|
||||
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
|
||||
std::collections::HashMap::new(),
|
||||
)
|
||||
(super::session_bytes_text(name, bytes)?, std::collections::HashMap::new())
|
||||
};
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let mut missing = Vec::new();
|
||||
@@ -149,6 +148,38 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
|
||||
mod bundle_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_session_too_large_to_parse_is_refused_from_bytes() {
|
||||
// #892: a session the browser hands over as bytes has the cap a session file has. The
|
||||
// bytes are zeroed on allocation and never read, so they take no memory.
|
||||
let huge = vec![0u8; usize::try_from(super::super::MAX_SESSION_BYTES).unwrap() + 1];
|
||||
let err = open_session_bytes(&mut Engine::default(), "Huge.scraft", &huge).unwrap_err().to_string();
|
||||
assert_eq!(err, "Huge.scraft is too large to open (over 256 MiB)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundles_and_bare_sessions_keep_to_the_session_limit_both_ways() {
|
||||
// #892, with the limit lowered for this test: a bundle is saved only if its session can
|
||||
// be opened again (#433), and its reader and the bare-bytes reader refuse one past it.
|
||||
let e = crate::demo::demo_engine();
|
||||
let (bundle, fresh) = session_bundle(e.session()).unwrap();
|
||||
let inside = fresh.to_json().unwrap().len() as u64;
|
||||
let bare = e.session().to_json().unwrap();
|
||||
{
|
||||
let _limit = super::super::SessionLimit::set(inside.max(bare.len() as u64));
|
||||
assert!(session_bundle(e.session()).is_ok());
|
||||
assert!(open_session_bytes(&mut Engine::default(), "Song.scraftz", &bundle).is_ok());
|
||||
assert!(open_session_bytes(&mut Engine::default(), "Song.scraft", bare.as_bytes()).is_ok());
|
||||
}
|
||||
let _limit = super::super::SessionLimit::set(inside.min(bare.len() as u64) - 1);
|
||||
let err = session_bundle(e.session()).unwrap_err().to_string();
|
||||
assert!(err.starts_with("the session is too large to save"), "{err}");
|
||||
let err = open_session_bytes(&mut Engine::default(), "Song.scraftz", &bundle).unwrap_err().to_string();
|
||||
assert!(err.starts_with("bundle: ") && err.contains(".scraft is too large to open"), "{err}");
|
||||
let err = open_session_bytes(&mut Engine::default(), "Song.scraft", bare.as_bytes()).unwrap_err().to_string();
|
||||
assert!(err.starts_with("Song.scraft is too large to open"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_round_trips_session_and_audio() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
@@ -194,6 +225,23 @@ mod bundle_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_sharing_one_payload_are_refused() {
|
||||
use std::io::Write as _;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file("Song.scraft", opts).unwrap();
|
||||
w.write_all(Session::default().to_json().unwrap().as_bytes()).unwrap();
|
||||
w.start_file("Audio Files/a.wav", opts).unwrap();
|
||||
w.write_all(&[7u8; 4096]).unwrap();
|
||||
for i in 0..100 {
|
||||
w.shallow_copy_file("Audio Files/a.wav", &format!("Audio Files/copy{i}.wav")).unwrap();
|
||||
}
|
||||
let bundle = w.finish().unwrap().into_inner();
|
||||
let err = read_bundle(&bundle, BUNDLE_LIMITS).unwrap_err();
|
||||
assert!(err.to_string().contains("share data"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bundle_is_saved_only_if_it_opens_again() {
|
||||
// #433: the writer keeps to the reader's limits.
|
||||
|
||||
@@ -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 {
|
||||
@@ -66,15 +82,37 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
|
||||
for c in t.clips() {
|
||||
if let ClipContent::Midi { sequence } = &c.content {
|
||||
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
||||
for n in &sequence.notes {
|
||||
// Only what plays: notes starting inside the clip, cut at its end.
|
||||
let clip_end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
|
||||
for n in sequence.notes.iter().filter(|n| n.start.saturating_add(base) < clip_end) {
|
||||
let mut n = *n;
|
||||
n.start = n.start.saturating_add(base);
|
||||
n.length = n.length.min(clip_end - n.start).max(1);
|
||||
seq.notes.push(n);
|
||||
}
|
||||
for ev in &sequence.ctrls {
|
||||
let mut kept: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
|
||||
for ev in sequence.ctrls.iter().filter(|ev| ev.tick.saturating_add(base) < clip_end) {
|
||||
let mut ev = *ev;
|
||||
ev.tick = ev.tick.saturating_add(base);
|
||||
seq.ctrls.push(ev);
|
||||
kept.push(ev);
|
||||
}
|
||||
// A sustain still held at the clip end is released there.
|
||||
let mut release: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
|
||||
for ev in &kept {
|
||||
if ev.kind != soundcraft_midi::CtrlKind::Cc(64) {
|
||||
continue;
|
||||
}
|
||||
match release.iter_mut().find(|r| r.channel == ev.channel) {
|
||||
Some(r) if ev.tick >= r.tick => *r = *ev,
|
||||
Some(_) => {}
|
||||
None => release.push(*ev),
|
||||
}
|
||||
}
|
||||
seq.ctrls.append(&mut kept);
|
||||
for mut r in release.into_iter().filter(|r| r.value >= 64) {
|
||||
r.tick = clip_end;
|
||||
r.value = 0;
|
||||
seq.ctrls.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::bundle::save_session_bundle;
|
||||
use super::import::add_source;
|
||||
use super::import::{MAX_IMPORT_SAMPLES, at_rate, relink_videos};
|
||||
use super::{
|
||||
Arc, Engine, EngineError, OsStr, Path, PathBuf, Result, Session, SourceAudio, file_key, read_file, read_text, stored_path, write_session,
|
||||
Arc, Engine, EngineError, OsStr, Path, PathBuf, Result, Session, SourceAudio, file_key, read_file, read_session_text, stored_path, write_session,
|
||||
};
|
||||
|
||||
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
|
||||
@@ -44,7 +44,7 @@ pub fn open_session(e: &mut Engine, path: impl AsRef<Path>) -> Result<Vec<String
|
||||
/// [`open_session`], following the media paths in it as if they were stored in the session file
|
||||
/// `links` (crash recovery: the session written beside the autosave, its paths from the original).
|
||||
pub(crate) fn open_session_linked(e: &mut Engine, path: &Path, links: &Path) -> Result<Vec<String>> {
|
||||
let text = read_text(path)?;
|
||||
let text = read_session_text(path)?;
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut missing = Vec::new();
|
||||
@@ -98,6 +98,96 @@ mod save_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A new, empty scratch folder, removed again when the test ends, passed or not.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_file_too_large_to_parse_is_refused_unread() {
|
||||
// #892: a session file is read whole and parsed; past the cap it is refused by its size.
|
||||
// The file's length is set, not written, so it takes no time or space to make.
|
||||
let dir = Scratch::new("huge-session");
|
||||
let path = dir.0.join("Huge.scraft");
|
||||
std::fs::File::create(&path).unwrap().set_len(crate::io::MAX_SESSION_BYTES + 1).unwrap();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let err = open_session(&mut e, &path).unwrap_err().to_string();
|
||||
assert_eq!(err, format!("{} is too large to open (over 256 MiB)", path.display()));
|
||||
assert_eq!(e.session().tracks.len(), crate::demo::demo_engine().session().tracks.len());
|
||||
let err = e.execute("file.import_session_data", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(err.contains("too large to open"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_too_large_to_open_is_not_saved_and_leaves_nothing_behind() {
|
||||
// #892: what SoundCraft saves, it opens again. A refused save writes no audio and no
|
||||
// session; the limit is lowered for this test so a small session reaches it.
|
||||
let dir = Scratch::new("refused-save");
|
||||
let path = |p: &str| dir.0.join(p).to_string_lossy().into_owned();
|
||||
save_session(&mut with_take(0.5), &path("A/Song.scraft")).unwrap();
|
||||
let saved = std::fs::metadata(path("A/Song.scraft")).unwrap().len();
|
||||
let untouched = |sub: &str| assert!(!dir.0.join(sub).exists(), "{sub} was written");
|
||||
{
|
||||
let _limit = crate::io::SessionLimit::set(saved - 1);
|
||||
let mut e = with_take(0.5);
|
||||
let err = save_session(&mut e, &path("B/Song.scraft")).unwrap_err().to_string();
|
||||
assert_eq!(err, format!("{} is too large to save: over {} bytes, more than SoundCraft opens", path("B/Song.scraft"), saved - 1));
|
||||
assert!(e.is_dirty() && e.path.is_none());
|
||||
let err = e.execute("session.save_copy", &json!({"path": path("C/Song.scraft")})).unwrap_err().to_string();
|
||||
assert!(err.contains("too large to save"), "{err}");
|
||||
for sub in ["B", "C"] {
|
||||
untouched(sub);
|
||||
}
|
||||
// An open refuses the session saved before, now over the limit.
|
||||
let err = open_session(&mut Engine::default(), path("A/Song.scraft")).unwrap_err().to_string();
|
||||
assert!(err.contains("too large to open"), "{err}");
|
||||
}
|
||||
{
|
||||
// A template holds no audio or clips: a smaller limit refuses it.
|
||||
let _limit = crate::io::SessionLimit::set(100);
|
||||
std::fs::create_dir_all(dir.0.join("D")).unwrap();
|
||||
let err = with_take(0.5).execute("session.save_template", &json!({"path": path("D/Song.scraft")})).unwrap_err().to_string();
|
||||
assert!(err.contains("too large to save"), "{err}");
|
||||
untouched("D/Song.scraft");
|
||||
}
|
||||
// Checked before the audio file is named, against the longest name it can get: a
|
||||
// session at the limit plus that margin is saved, and is the same size as before.
|
||||
let _limit = crate::io::SessionLimit::set(saved + 5);
|
||||
save_session(&mut with_take(0.5), &path("B/Song.scraft")).unwrap();
|
||||
assert_eq!(std::fs::metadata(path("B/Song.scraft")).unwrap().len(), saved);
|
||||
assert_eq!(reopen(&path("B/Song.scraft")).session().sources.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_session_parses_as_it_did_through_a_tree() {
|
||||
// #892: parsing the text directly gives the document the `Value` round trip gave.
|
||||
let mut e = crate::demo::demo_engine();
|
||||
e.execute("mix.volume", &json!({"track": "Kick", "db": -7.0})).unwrap();
|
||||
let text = e.session().to_json().unwrap();
|
||||
let mut old: Session = serde_json::from_value(serde_json::from_str(&text).unwrap()).unwrap();
|
||||
old.sanitize();
|
||||
assert_eq!(Session::from_json(&text).unwrap(), old);
|
||||
let mut r = Engine::default();
|
||||
let dir = Scratch::new("reparse");
|
||||
let path = dir.0.join("Song.scraft").to_string_lossy().into_owned();
|
||||
save_session(&mut e, &path).unwrap();
|
||||
assert_eq!(open_session(&mut r, &path).unwrap(), Vec::<String>::new());
|
||||
assert_eq!(r.session().tracks, e.session().tracks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_copy_leaves_document_untouched_and_reuses_audio() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
+246
-18
@@ -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,
|
||||
@@ -108,6 +119,8 @@ pub struct Clipboard {
|
||||
pub markers: Vec<soundcraft_model::MemoryLocation>,
|
||||
/// How far the copied range started after a grid line; Relative Grid pastes keep it.
|
||||
pub grid_offset: Samples,
|
||||
/// Original insertion point when the clipboard was populated by Cut.
|
||||
pub cut_from: Option<Samples>,
|
||||
/// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too.
|
||||
pub automation_only: bool,
|
||||
/// The Auto Rename schemes of the copied clips, keyed as in their `auto_name`.
|
||||
@@ -117,7 +130,7 @@ pub struct Clipboard {
|
||||
impl Clipboard {
|
||||
/// An empty clipboard for material copied from `range`.
|
||||
pub fn of_range(s: &Session, range: soundcraft_time::Range) -> Clipboard {
|
||||
let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
let line = s.grid_line_at_or_before(range.start);
|
||||
Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() }
|
||||
}
|
||||
|
||||
@@ -173,8 +186,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 +230,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,
|
||||
@@ -267,6 +281,13 @@ impl Engine {
|
||||
Arc::make_mut(&mut self.doc)
|
||||
}
|
||||
|
||||
/// Mutable access for live state that redraws and reaches playback but is not part of the
|
||||
/// saved document. Callers must also keep the fields out of serialization and Undo/Redo.
|
||||
pub(crate) fn runtime_session_mut(&mut self) -> &mut Session {
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
Arc::make_mut(&mut self.doc)
|
||||
}
|
||||
|
||||
/// Like `session_mut`, for `edit.zoom` only and without bumping `revision`.
|
||||
pub fn zoom_mut(&mut self) -> &mut ZoomState {
|
||||
self.dirty = true;
|
||||
@@ -322,20 +343,34 @@ impl Engine {
|
||||
self.undo.iter().map(|h| h.label.clone()).collect()
|
||||
}
|
||||
|
||||
/// Bound Undo and Redo together, dropping farthest redo entries before oldest undo entries.
|
||||
pub fn set_history_limit(&mut self, limit: usize) {
|
||||
let limit = limit.max(1);
|
||||
self.history_limit = limit;
|
||||
let excess = self.undo.len().saturating_add(self.redo.len()).saturating_sub(limit);
|
||||
let from_redo = excess.min(self.redo.len());
|
||||
self.redo.drain(..from_redo);
|
||||
self.undo.drain(..excess - from_redo);
|
||||
}
|
||||
|
||||
/// Restore the previous document while retaining current edit mode and live input flags.
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some(h) = self.undo.pop() else { return false };
|
||||
let cur = std::mem::replace(&mut self.doc, h.doc);
|
||||
self.keep_edit_mode(&cur);
|
||||
self.keep_runtime_track_states(&cur);
|
||||
self.redo.push(HistoryEntry { label: h.label, doc: cur });
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.dirty = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Restore the next document while retaining current edit mode and live input flags.
|
||||
pub fn redo(&mut self) -> bool {
|
||||
let Some(h) = self.redo.pop() else { return false };
|
||||
let cur = std::mem::replace(&mut self.doc, h.doc);
|
||||
self.keep_edit_mode(&cur);
|
||||
self.keep_runtime_track_states(&cur);
|
||||
self.undo.push(HistoryEntry { label: h.label, doc: cur });
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.dirty = true;
|
||||
@@ -356,6 +391,25 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo and Redo must not restore live input states captured in document snapshots. A track
|
||||
/// absent from the current document comes back safe rather than inheriting a historical arm.
|
||||
fn keep_runtime_track_states(&mut self, from: &Session) {
|
||||
let states: std::collections::HashMap<_, _> = from
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|track| track.mixer.record_arm || track.mixer.input_monitor)
|
||||
.map(|track| (track.id, (track.mixer.record_arm, track.mixer.input_monitor)))
|
||||
.collect();
|
||||
let state = |id| states.get(&id).copied().unwrap_or((false, false));
|
||||
// Keep shared snapshots untouched when the restored flags already match.
|
||||
if self.doc.tracks.iter().all(|track| (track.mixer.record_arm, track.mixer.input_monitor) == state(track.id)) {
|
||||
return;
|
||||
}
|
||||
for track in &mut Arc::make_mut(&mut self.doc).tracks {
|
||||
(track.mixer.record_arm, track.mixer.input_monitor) = state(track.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an undo point manually (for interactive gestures committed by the UI).
|
||||
pub fn push_undo(&mut self, label: &str, before: Arc<Session>) {
|
||||
if Arc::ptr_eq(&before, &self.doc) {
|
||||
@@ -419,7 +473,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 +540,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 +575,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 +590,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::*;
|
||||
@@ -589,6 +726,59 @@ mod merge_tests {
|
||||
mod dirty_tests {
|
||||
use super::*;
|
||||
|
||||
/// Lowering the cap immediately removes the oldest retained Undo steps.
|
||||
#[test]
|
||||
fn reducing_the_history_limit_drops_the_oldest_steps_immediately() {
|
||||
let mut e = Engine::default();
|
||||
for _ in 0..4 {
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(e.undo_history().len(), 4);
|
||||
e.set_history_limit(2);
|
||||
assert_eq!(e.undo_history().len(), 2);
|
||||
assert!(e.undo());
|
||||
assert!(e.undo());
|
||||
assert!(!e.undo());
|
||||
}
|
||||
|
||||
/// A shared history cap retains the closest redo steps without letting either direction exceed it.
|
||||
#[test]
|
||||
fn lowering_history_limit_bounds_undo_and_redo_together() {
|
||||
for (requested, past, future) in [(0, 1, 0), (1, 1, 0), (2, 2, 0), (3, 2, 1), (4, 2, 2), (5, 2, 3), (usize::MAX, 2, 3)] {
|
||||
let mut e = Engine::default();
|
||||
for _ in 0..5 {
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
}
|
||||
for _ in 0..3 {
|
||||
assert!(e.undo());
|
||||
}
|
||||
e.mark_clean();
|
||||
e.set_history_limit(requested);
|
||||
let limit = requested.max(1);
|
||||
assert_eq!(e.history_limit, limit);
|
||||
assert_eq!((e.undo.len(), e.redo.len()), (past, future), "requested {requested}");
|
||||
assert!(!e.is_dirty(), "history capacity is not a document edit");
|
||||
assert_eq!(e.session().tracks.len(), 2);
|
||||
for step in 1..=future {
|
||||
assert!(e.redo());
|
||||
assert_eq!(e.session().tracks.len(), 2 + step, "keep the nearest redo entries");
|
||||
assert!(e.undo.len() + e.redo.len() <= limit);
|
||||
}
|
||||
assert!(!e.redo());
|
||||
for step in 1..=past + future {
|
||||
assert!(e.undo());
|
||||
assert_eq!(e.session().tracks.len(), 2 + future - step, "keep the nearest undo entries");
|
||||
assert!(e.undo.len() + e.redo.len() <= limit);
|
||||
}
|
||||
assert!(!e.undo());
|
||||
for _ in 0..past + future {
|
||||
assert!(e.redo());
|
||||
assert!(e.undo.len() + e.redo.len() <= limit);
|
||||
}
|
||||
assert!(!e.redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_locating_and_zooming_leave_the_session_clean() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
@@ -607,6 +797,44 @@ mod dirty_tests {
|
||||
e.undo();
|
||||
assert!(!e.is_dirty());
|
||||
}
|
||||
|
||||
/// Unchanged runtime flags do not detach a shared document snapshot.
|
||||
#[test]
|
||||
fn matching_live_states_keep_the_document_shared() {
|
||||
let mut e = Engine::default();
|
||||
let track = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
|
||||
e.runtime_session_mut().track_mut(track).unwrap().mixer.record_arm = true;
|
||||
let snapshot = e.session_arc();
|
||||
e.keep_runtime_track_states(&snapshot);
|
||||
assert!(Arc::ptr_eq(&snapshot, &e.session_arc()));
|
||||
}
|
||||
|
||||
#[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)]
|
||||
|
||||
+276
-16
@@ -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;
|
||||
@@ -126,12 +146,20 @@ fn measure_grid(s: &Session, end_unit: i64) -> Vec<(i64, i64, u32, u32)> {
|
||||
let a = s.tempo.bar_start_tick(bar);
|
||||
let b = s.tempo.bar_start_tick(bar + 1);
|
||||
let m = s.tempo.meter_at_tick(a);
|
||||
let (ua, ub) = (a / UNIT, b / UNIT);
|
||||
if ub <= ua {
|
||||
if b <= a {
|
||||
break;
|
||||
}
|
||||
v.push((ua, ub - ua, m.numerator, m.denominator));
|
||||
let (ua, ub) = (a / UNIT, b / UNIT);
|
||||
bar += 1;
|
||||
if ub <= ua {
|
||||
// A bar shorter than a sixteenth that does not reach the next unit: the following
|
||||
// bar carries this span, so the grid stays contiguous.
|
||||
continue;
|
||||
}
|
||||
// Bars shorter than a sixteenth are written as one measure of the units they cover, so
|
||||
// the time signature matches the measure's content.
|
||||
let (num, den) = if m.ticks_per_bar() < UNIT { (u32::try_from(ub - ua).unwrap_or(1), 16) } else { (m.numerator, m.denominator) };
|
||||
v.push((ua, ub - ua, num, den));
|
||||
if ub >= end_unit.max(1) || v.len() >= MAX_MEASURES {
|
||||
break;
|
||||
}
|
||||
@@ -151,13 +179,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 +232,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 +273,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 +296,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 +320,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 +387,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,14 +405,22 @@ 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
|
||||
const STAFF_GAP: f64 = 70.0;
|
||||
const SYSTEM_GAP: f64 = 40.0;
|
||||
let n_measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
|
||||
let per = setup.bars_per_system.max(1) as usize;
|
||||
// The layout range the setup offers (1 to 16): a caller's own value can't multiply the
|
||||
// barlines drawn per system beyond it.
|
||||
let per = setup.bars_per_system.clamp(1, 16) as usize;
|
||||
let systems = n_measures.div_ceil(per).max(1);
|
||||
let label_w = if setup.show_track_names { 70.0 } else { 0.0 };
|
||||
let staff_x0 = MARGIN + label_w + 34.0;
|
||||
@@ -350,7 +435,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 +549,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 +565,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 +647,175 @@ 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"));
|
||||
}
|
||||
|
||||
/// Note onsets (pitch count of every chord that is not a tie continuation).
|
||||
fn note_starts(parts: &[Part]) -> usize {
|
||||
parts.iter().flat_map(|p| &p.measures).flat_map(|m| &m.items).filter(|i| !i.tie_stop).map(|i| i.pitches.len()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bars_shorter_than_a_sixteenth_keep_every_note() {
|
||||
let base = crate::demo::demo_engine();
|
||||
let baseline = note_starts(&layout(base.session(), &[]));
|
||||
assert!(baseline > 0, "demo has notes");
|
||||
for (n, d) in [(1, 32), (1, 64), (2, 64), (3, 64)] {
|
||||
for mid in [false, true] {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let tick = if mid { e.session().tempo.bar_start_tick(5) } else { 0 };
|
||||
e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter");
|
||||
let parts = layout(e.session(), &[]);
|
||||
assert!(parts.iter().all(|p| !p.measures.is_empty()), "{n}/{d} mid={mid}: empty part");
|
||||
assert_eq!(note_starts(&parts), baseline, "{n}/{d} mid={mid}: notes lost");
|
||||
for p in &parts {
|
||||
for m in &p.measures {
|
||||
let units: u32 = m.items.iter().map(|i| i.units).sum();
|
||||
// The written meter matches the measure's content (divisions are sixteenths).
|
||||
assert_eq!(units * m.denominator, 16 * m.numerator, "{n}/{d} mid={mid}: meter/content mismatch");
|
||||
}
|
||||
}
|
||||
let setup = ScoreSetup::from_session(e.session());
|
||||
let x = musicxml(&parts, &setup).expect("musicxml fits");
|
||||
assert!(x.contains("<pitch>"), "{n}/{d} mid={mid}: musicxml has no notes");
|
||||
let s = svg(&parts, &setup).expect("svg fits");
|
||||
assert!(s.contains("<ellipse"), "{n}/{d} mid={mid}: svg has no notes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bars_of_a_sixteenth_a_32nd_and_a_64th_in_a_row_keep_a_contiguous_grid() {
|
||||
let base = crate::demo::demo_engine();
|
||||
let baseline = note_starts(&layout(base.session(), &[]));
|
||||
let mut e = crate::demo::demo_engine();
|
||||
// Bars 5 to 8 are 1/16, 1/32, 1/64 and 3/64 long, then 4/4 resumes.
|
||||
for (bar, (n, d)) in (5..).zip([(1, 16), (1, 32), (1, 64), (3, 64), (4, 4)]) {
|
||||
let tick = e.session().tempo.bar_start_tick(bar);
|
||||
e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter");
|
||||
}
|
||||
let grid = measure_grid(e.session(), i64::MAX);
|
||||
assert_eq!(grid.first().map(|m| m.0), Some(0));
|
||||
for w in grid.windows(2) {
|
||||
assert_eq!(w[0].0 + w[0].1, w[1].0, "measures leave no gap or overlap: {w:?}");
|
||||
}
|
||||
assert_eq!(note_starts(&layout(e.session(), &[])), baseline, "notes lost");
|
||||
}
|
||||
|
||||
#[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 bars_per_system_is_held_to_the_supported_range() {
|
||||
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: u32::MAX, show_track_names: false };
|
||||
let part = Part { name: "p".into(), bass: false, measures: vec![Measure { numerator: 4, denominator: 4, items: Vec::new() }] };
|
||||
let out = svg_budgeted(std::slice::from_ref(&part), &setup, 1 << 20).unwrap();
|
||||
assert_eq!(out.matches("stroke-width=\"1\"").count(), 16, "sixteen barlines, not billions");
|
||||
}
|
||||
|
||||
#[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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,38 @@ fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A WAV bounce is a Broadcast Wave stamped with where it starts on the session's timecode (`bext`
|
||||
/// TimeReference, samples since midnight), as other DAWs write it, so post tools can place it.
|
||||
#[test]
|
||||
fn wav_bounces_carry_their_timecode_position_as_a_bwf_time_reference() {
|
||||
let mut e = soundcraft_engine::demo::demo_engine();
|
||||
let sr = u64::from(e.session().sample_rate.hz());
|
||||
// The session starts at 01:00:00:00; the bounce at 2 s into it.
|
||||
e.session_mut().timecode_start = (3600 * sr) as i64;
|
||||
let (start, end) = (2 * sr, 3 * sr);
|
||||
let dir = scratch("bounce-bext");
|
||||
let stamp = |path: &std::path::Path| {
|
||||
let ext = path.extension().and_then(|x| x.to_str());
|
||||
soundcraft_audio_io::probe(&std::fs::read(path).unwrap(), ext).unwrap().bwf.map(|b| b.time_reference)
|
||||
};
|
||||
let mix = dir.join("Mix.wav");
|
||||
e.execute("file.bounce_mix", &serde_json::json!({"path": mix, "start": start, "end": end})).unwrap();
|
||||
assert_eq!(stamp(&mix), Some(3600 * sr + start), "mix");
|
||||
let stems = dir.join("stems");
|
||||
let files = e.execute("file.bounce_stems", &serde_json::json!({"dir": stems, "start": start, "end": end})).unwrap();
|
||||
let files = files["files"].as_array().unwrap();
|
||||
assert!(!files.is_empty());
|
||||
for f in files {
|
||||
let f = std::path::PathBuf::from(f.as_str().unwrap());
|
||||
assert_eq!(stamp(&f), Some(3600 * sr + start), "{}", f.display());
|
||||
}
|
||||
// A session that starts at zero still says where the bounce starts.
|
||||
e.session_mut().timecode_start = 0;
|
||||
e.execute("file.bounce_mix", &serde_json::json!({"path": mix, "start": start, "end": end})).unwrap();
|
||||
assert_eq!(stamp(&mix), Some(start));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_an_unfinished_bounce_cancels_it_and_keeps_the_old_file() {
|
||||
let e = soundcraft_engine::demo::demo_engine();
|
||||
|
||||
@@ -220,6 +220,129 @@ fn relative_grid_pastes_keep_the_offset_from_the_grid() {
|
||||
assert_eq!(clips(&e, t).last(), Some(&(5 * step, 1000)));
|
||||
}
|
||||
|
||||
/// A relative paste restores the insertion point even when Cut creates a new boundary there.
|
||||
#[test]
|
||||
fn relative_grid_cut_pastes_back_at_its_insertion_boundary() {
|
||||
let (mut e, t, _) = long("relative_grid", &[(1_000, 2_000, 0)]);
|
||||
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
|
||||
run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 1_500, "end": 2_000}));
|
||||
run(&mut e, "edit.paste", json!({"tracks": [t.0]}));
|
||||
assert_eq!(e.session().edit.selection.start, 1_500);
|
||||
assert!(clips(&e, t).contains(&(1_500, 500)), "{:?}", clips(&e, t));
|
||||
}
|
||||
|
||||
/// Moving a group excludes all its clips from Clips/Markers snap candidates.
|
||||
#[test]
|
||||
fn clips_markers_grid_ignores_every_clip_being_moved() {
|
||||
let (mut e, _, ids) = long("grid", &[(1_000, 1_000, 0), (4_000, 500, 2_000)]);
|
||||
e.session_mut().add_marker("Target", soundcraft_model::MarkerKind::Marker, 2_500, 2_500);
|
||||
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "to": 1_000}));
|
||||
assert_eq!([start(&e, ids[0]), start(&e, ids[1])], [1_000, 4_000]);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "to": 2_200}));
|
||||
assert_eq!(start(&e, ids[0]), 2_500);
|
||||
assert_eq!(start(&e, ids[1]), 5_500);
|
||||
}
|
||||
|
||||
/// Zero and clamped moves preserve overlaps while off-grid quantization still changes position.
|
||||
#[test]
|
||||
fn zero_distance_move_keeps_overlapping_material_and_quantize_still_moves() {
|
||||
let (mut e, t, ids) = long("slip", &[(1_000, 1_000, 0), (1_500, 1_000, 2_000)]);
|
||||
let before = material(&e, t);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": 0}));
|
||||
assert_eq!(material(&e, t), before);
|
||||
|
||||
run(&mut e, "edit.select", json!({"clips": [ids[0].0]}));
|
||||
run(&mut e, "clip.quantize_to_grid", json!({}));
|
||||
assert_eq!(start(&e, ids[0]), 0);
|
||||
|
||||
let (mut e, t, ids) = long("slip", &[(0, 1_000, 0), (500, 1_000, 2_000)]);
|
||||
let before = material(&e, t);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": -1_000}));
|
||||
assert_eq!(material(&e, t), before);
|
||||
}
|
||||
|
||||
/// A vertical move remains a real edit and snaps its off-grid start on the destination track.
|
||||
#[test]
|
||||
fn zero_horizontal_grid_move_to_another_track_still_snaps() {
|
||||
let (mut e, _, [_, clip]) = engine("grid");
|
||||
let destination = new_track(&mut e);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [clip.0], "to": 2_000, "track": destination.0}));
|
||||
let (track, moved) = e.session().find_clip(clip).unwrap();
|
||||
assert_eq!(track, destination);
|
||||
assert_eq!(moved.start, 0);
|
||||
}
|
||||
|
||||
/// Nudge and quantization must preserve overlapping material when the clip cannot move.
|
||||
#[test]
|
||||
fn stationary_nudges_and_quantization_preserve_overlaps() {
|
||||
for (command, params) in [("edit.nudge", json!({"direction": -1})), ("clip.quantize_to_grid", json!({}))] {
|
||||
let (mut e, t, ids) = long("slip", &[(0, 1_000, 0), (500, 1_000, 2_000)]);
|
||||
run(&mut e, "edit.select", json!({"clips": [ids[0].0]}));
|
||||
let before = e.session().track(t).unwrap().clips().to_vec();
|
||||
let result = e.execute(command, ¶ms).unwrap();
|
||||
assert_eq!(e.session().track(t).unwrap().clips(), before, "{command}");
|
||||
if command == "edit.nudge" {
|
||||
assert_eq!(result["moved"], 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every Nudge scale shifts clips and insertion across a tempo change and drop-frame rates.
|
||||
#[test]
|
||||
fn every_nudge_scale_moves_by_its_selected_increment() {
|
||||
use soundcraft_time::{FrameRate, SampleRate, TICKS_PER_QUARTER};
|
||||
for rate in [FrameRate::Fps30, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
for at in [48_000, 144_000] {
|
||||
let cases = [
|
||||
(json!("1/4"), if at < 96_000 { 24_000 } else { 32_000 }),
|
||||
(json!({"seconds": 0.5}), 24_000),
|
||||
(json!({"timecode": "5_frames"}), rate.samples_at_frame(5, SampleRate::HZ_48000)),
|
||||
(json!({"feet_frames": "foot"}), rate.samples_at_frame(16, SampleRate::HZ_48000)),
|
||||
(json!({"samples": 100}), 100),
|
||||
];
|
||||
for (value, expected) in cases {
|
||||
let (mut e, track, ids) = long("slip", &[(at, 1_000, 0)]);
|
||||
e.session_mut().frame_rate = rate;
|
||||
e.session_mut().tempo.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap();
|
||||
run(&mut e, "edit.select", json!({"tracks": [track.0], "clips": [ids[0].0], "start": at, "end": at}));
|
||||
run(&mut e, "view.nudge", json!({"value": value, "follow_main": false}));
|
||||
let result = e.execute("edit.nudge", &json!({"direction": 1})).unwrap();
|
||||
assert_eq!(result["by"], expected, "{rate:?} at {at}");
|
||||
assert_eq!(result["moved"], 1);
|
||||
assert_eq!(start(&e, ids[0]), at + expected);
|
||||
assert_eq!(e.session().edit.selection.start, at + expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Separation refuses oversized numeric grids before splitting any material.
|
||||
#[test]
|
||||
fn separate_on_grid_refuses_a_truncated_boundary_set() {
|
||||
let (mut e, t, _) = long("grid", &[(0, 20_000, 0)]);
|
||||
run(&mut e, "view.grid", json!({"value": {"samples": 1}, "follow_main": false}));
|
||||
assert!(e.execute("edit.separate_on_grid", &json!({"tracks": [t.0], "start": 0, "end": 10_002})).is_err());
|
||||
assert_eq!(clips(&e, t), [(0, 20_000)]);
|
||||
}
|
||||
|
||||
/// Display thinning must never hide an oversized Clips/Markers separation request.
|
||||
#[test]
|
||||
fn separate_on_grid_refuses_dense_markers_before_changing_clips() {
|
||||
let (mut e, track, _) = long("grid", &[(0, 100_000, 0)]);
|
||||
let s = e.session_mut();
|
||||
for at in 1..=10_001 {
|
||||
s.add_marker("Boundary", soundcraft_model::MarkerKind::Marker, at, at);
|
||||
}
|
||||
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
|
||||
let before = e.session().clone();
|
||||
let undo_len = e.undo_history().len();
|
||||
let error = e.execute("edit.separate_on_grid", &json!({"tracks": [track.0], "start": 0, "end": 100_000})).unwrap_err();
|
||||
assert!(error.to_string().contains("10,000"), "{error}");
|
||||
assert_eq!(e.session(), &before);
|
||||
assert_eq!(e.undo_history().len(), undo_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shuffle_moves_keep_crossfade_partners_whole() {
|
||||
let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]);
|
||||
@@ -299,6 +422,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 +552,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,13 @@ mod hostile_params;
|
||||
mod hostile_sessions;
|
||||
mod long_renders;
|
||||
mod midi_offset;
|
||||
mod min_secs_with_timecode_counter;
|
||||
mod mix_required_params;
|
||||
mod new_track_rows;
|
||||
mod random_edits;
|
||||
mod recording;
|
||||
mod session_transport;
|
||||
mod split_into_mono;
|
||||
mod time_key_signatures;
|
||||
mod track_name_collisions;
|
||||
mod trim_to_selection;
|
||||
|
||||
@@ -28,3 +28,71 @@ fn midi_track_offset_moves_the_notes_like_moving_the_clip() {
|
||||
e.execute("edit.move_clips", &json!({"clip": clip, "by": 24_000})).unwrap();
|
||||
assert!(render(&e) == offset, "the offset must match moving the clip by 24000 samples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_midi_leaves_out_what_a_trimmed_clip_no_longer_plays() {
|
||||
let mut e = Engine::default();
|
||||
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Trim", "instrument": "subtractive_synth"})).unwrap();
|
||||
let track = r["tracks"][0].as_u64().unwrap();
|
||||
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0, "length_ticks": 960, "velocity": 100})).unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 62, "start_ticks": 800, "length_ticks": 960, "velocity": 100})).unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 64, "start_ticks": 2000, "length_ticks": 960, "velocity": 100})).unwrap();
|
||||
// 24000 samples is 960 ticks at 120 bpm.
|
||||
let id = e.session().tracks[0].clips()[0].id;
|
||||
e.session_mut().find_clip_mut(id).unwrap().trim_end_to(24_000);
|
||||
|
||||
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
|
||||
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
|
||||
let notes = &smf.tracks[0].sequence.notes;
|
||||
assert_eq!(notes.len(), 2, "only notes starting inside the clip are exported");
|
||||
let late = notes.iter().find(|n| n.pitch == 62).unwrap();
|
||||
assert_eq!(late.start + late.length, 960, "a note running past the clip end is cut at it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_midi_releases_a_sustain_held_past_the_trim() {
|
||||
use soundcraft_midi::{CtrlEvent, CtrlKind};
|
||||
let mut e = Engine::default();
|
||||
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Sus", "instrument": "subtractive_synth"})).unwrap();
|
||||
let track = r["tracks"][0].as_u64().unwrap();
|
||||
e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
|
||||
let id = e.session().tracks[0].clips()[0].id;
|
||||
{
|
||||
let clip = e.session_mut().find_clip_mut(id).unwrap();
|
||||
if let soundcraft_model::ClipContent::Midi { sequence } = &mut clip.content {
|
||||
let cc = |tick, channel, value| CtrlEvent { tick, channel, kind: CtrlKind::Cc(64), value };
|
||||
sequence.ctrls = vec![cc(100, 0, 127), cc(200, 1, 127), cc(300, 1, 0), cc(2000, 0, 0)];
|
||||
}
|
||||
clip.trim_end_to(24_000);
|
||||
}
|
||||
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
|
||||
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
|
||||
let ctrls = &smf.tracks[0].sequence.ctrls;
|
||||
let sus: Vec<_> = ctrls.iter().filter(|c| c.kind == CtrlKind::Cc(64)).collect();
|
||||
assert!(sus.iter().any(|c| c.channel == 0 && c.tick == 960 && c.value == 0), "{sus:?}");
|
||||
assert!(!sus.iter().any(|c| c.channel == 1 && c.tick == 960), "released channel gets no extra event: {sus:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_midi_keeps_the_notes_of_a_clip_trimmed_at_both_ends_in_place() {
|
||||
let mut e = Engine::default();
|
||||
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Both", "instrument": "subtractive_synth"})).unwrap();
|
||||
let track = r["tracks"][0].as_u64().unwrap();
|
||||
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
for (pitch, start) in [(60, 0), (62, 1000), (64, 2000)] {
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": pitch, "start_ticks": start, "length_ticks": 960, "velocity": 100})).unwrap();
|
||||
}
|
||||
// 24000 samples is 960 ticks and 72000 samples is 2880 ticks at 120 bpm.
|
||||
e.execute("edit.trim_start_to_insertion", &json!({"tracks": [track], "at": 24_000})).unwrap();
|
||||
e.execute("edit.trim_end_to_insertion", &json!({"tracks": [track], "at": 72_000})).unwrap();
|
||||
|
||||
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
|
||||
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
|
||||
let mut notes: Vec<_> = smf.tracks[0].sequence.notes.iter().map(|n| (n.pitch, n.start, n.length)).collect();
|
||||
notes.sort_unstable();
|
||||
// The note cut by the start trim is gone; the others keep their place and the last stops at the end.
|
||||
assert_eq!(notes, vec![(62, 1000, 960), (64, 2000, 880)]);
|
||||
}
|
||||
|
||||
@@ -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,82 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ChannelFormat, TrackKind};
|
||||
|
||||
/// Mixed rows preserve their order and share one selection and history step.
|
||||
#[test]
|
||||
fn mixed_rows_create_in_order_select_every_track_and_undo_in_one_step() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"name": "Existing"})).unwrap();
|
||||
let before = e.session().to_json().unwrap();
|
||||
let result = e
|
||||
.execute(
|
||||
"track.new",
|
||||
&json!({"rows": [
|
||||
{"count": 2, "kind": "audio", "format": "mono", "name": "Dialogue", "timebase": "samples"},
|
||||
{"kind": "aux", "format": "stereo", "name": "Reverb", "timebase": "samples"},
|
||||
{"kind": "instrument", "format": "stereo", "name": "Keys", "timebase": "ticks", "instrument": "subtractive_synth"},
|
||||
{"kind": "folder", "format": "stereo", "name": "Music"}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let s = e.session();
|
||||
let created = &s.tracks[1..];
|
||||
assert_eq!(created.len(), 5);
|
||||
assert_eq!(
|
||||
created.iter().map(|t| t.kind).collect::<Vec<_>>(),
|
||||
[TrackKind::Audio, TrackKind::Audio, TrackKind::Aux, TrackKind::Instrument, TrackKind::Folder]
|
||||
);
|
||||
assert_eq!(
|
||||
created.iter().map(|t| t.format).collect::<Vec<_>>(),
|
||||
[ChannelFormat::Mono, ChannelFormat::Mono, ChannelFormat::Stereo, ChannelFormat::Stereo, ChannelFormat::Stereo]
|
||||
);
|
||||
assert!(!created[0].ticks_timebase);
|
||||
assert!(created[3].ticks_timebase && created[3].instrument.is_some());
|
||||
assert_eq!(s.busses.len(), 1);
|
||||
let ids = created.iter().map(|t| t.id).collect::<Vec<_>>();
|
||||
assert_eq!(s.edit.selected_tracks, ids);
|
||||
assert_eq!(result["tracks"], json!(ids));
|
||||
let after = s.to_json().unwrap();
|
||||
assert!(e.undo());
|
||||
assert_eq!(e.session().to_json().unwrap(), before);
|
||||
assert!(e.redo());
|
||||
assert_eq!(e.session().to_json().unwrap(), after);
|
||||
}
|
||||
|
||||
/// A failure in a later row rolls the complete command back atomically.
|
||||
#[test]
|
||||
fn invalid_later_row_leaves_no_partial_creation_or_undo_step() {
|
||||
for field in [json!({"kind": "unknown"}), json!({"format": "unknown"})] {
|
||||
let mut e = Engine::default();
|
||||
let before = e.session().to_json().unwrap();
|
||||
assert!(e.execute("track.new", &json!({"rows": [{"kind": "folder"}, field]})).is_err());
|
||||
assert_eq!(e.session().to_json().unwrap(), before);
|
||||
assert!(!e.can_undo());
|
||||
assert!(e.journal.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Row structure and aggregate track count reject malformed or oversized requests.
|
||||
#[test]
|
||||
fn row_shape_and_total_size_are_bounded() {
|
||||
for rows in [json!(null), json!({}), json!([]), json!([true]), json!([{"rows": []}]), json!([{"count": 128}, {}]), json!(vec![json!({}); 129])] {
|
||||
let mut e = Engine::default();
|
||||
assert!(e.execute("track.new", &json!({"rows": rows})).is_err(), "{rows}");
|
||||
assert!(e.session().tracks.is_empty());
|
||||
assert!(!e.can_undo());
|
||||
}
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"rows": [{"count": 64}, {"count": 64}]})).unwrap();
|
||||
assert_eq!(e.session().tracks.len(), 128);
|
||||
}
|
||||
|
||||
/// Calls without `rows` retain the original defaults and per-row count clamp.
|
||||
#[test]
|
||||
fn legacy_single_row_parameters_keep_their_defaults_and_count_clamp() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"kind": "aux", "count": -1})).unwrap();
|
||||
assert_eq!(e.session().tracks.len(), 1);
|
||||
assert_eq!(e.session().tracks[0].format, ChannelFormat::Stereo);
|
||||
e.execute("track.new", &json!({"count": 200})).unwrap();
|
||||
assert_eq!(e.session().tracks.len(), 129);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user