mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-12 08:53:41 -06:00
Compare commits
179
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e0e034ee | ||
|
|
eeb5d653a5 | ||
|
|
c25b12ba46 | ||
|
|
296bfbb2e1 | ||
|
|
f7a5adb474 | ||
|
|
23242338a6 | ||
|
|
e01ea00cdd | ||
|
|
659ec7a8e2 | ||
|
|
90e069f626 | ||
|
|
7a4f9c911d | ||
|
|
60f97a99a5 | ||
|
|
d255ca4fe9 | ||
|
|
1fe1d06073 | ||
|
|
10e4bc7b87 | ||
|
|
1d696bb3ef | ||
|
|
9f342bf403 | ||
|
|
17649aa693 | ||
|
|
0e511b1259 | ||
|
|
927c4f7b72 | ||
|
|
69af50aac2 | ||
|
|
e86d09ab2a | ||
|
|
ad34dbf87e | ||
|
|
f780dcab37 | ||
|
|
ff4be04936 | ||
|
|
1483dc833b | ||
|
|
2c042c599f | ||
|
|
e94a7875d3 | ||
|
|
dc4f82ea9f | ||
|
|
df67089ccd | ||
|
|
44e80dcc24 | ||
|
|
ef58a90f4a | ||
|
|
3f6703f87f | ||
|
|
c4c09ca789 | ||
|
|
7c6315d999 | ||
|
|
0ea934290d | ||
|
|
20338e0832 | ||
|
|
ddeb7e791f | ||
|
|
507786ad7a | ||
|
|
a7b7fb949e | ||
|
|
16e495550c | ||
|
|
e1b22258bc | ||
|
|
61becd52c5 | ||
|
|
76cc2aeac6 | ||
|
|
26cafa85bb | ||
|
|
8cdcab2697 | ||
|
|
436a2e22a8 | ||
|
|
b6f9f150a1 | ||
|
|
fbd7789b4a | ||
|
|
4edd315adb | ||
|
|
654deabd54 | ||
|
|
6c917ea378 | ||
|
|
c05dfc0a8e | ||
|
|
6f54446423 | ||
|
|
24451d5c42 | ||
|
|
7d508e2e44 | ||
|
|
bb0e070ab9 | ||
|
|
7bf178835f | ||
|
|
e66a5e7c67 | ||
|
|
87f46cf23b | ||
|
|
54cb247e12 | ||
|
|
fca0130b72 | ||
|
|
77b788007a | ||
|
|
73eeb692fe | ||
|
|
bd75fe691b | ||
|
|
6c8a9dd300 | ||
|
|
d393730b02 | ||
|
|
4240ffda42 | ||
|
|
c046c3bb1b | ||
|
|
e6cbf76148 | ||
|
|
1db9563379 | ||
|
|
341a112757 | ||
|
|
b93a182ef7 | ||
|
|
bf2a6b52de | ||
|
|
594d0ade4f | ||
|
|
92e6973640 | ||
|
|
2b03df3ad4 | ||
|
|
b8e1e69134 | ||
|
|
b6946d3a94 | ||
|
|
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 | ||
|
|
e569fbc50e | ||
|
|
e1f10ccdc8 | ||
|
|
627bbbf9cc | ||
|
|
84baa5debc | ||
|
|
468bec487c | ||
|
|
71f899baac | ||
|
|
ccbeec6680 | ||
|
|
bdb73895a5 | ||
|
|
1a4a88fb0d | ||
|
|
9e902b48fe | ||
|
|
265e526f6a | ||
|
|
d2298ff974 | ||
|
|
cf40296888 | ||
|
|
381832447c | ||
|
|
b32065a109 | ||
|
|
97e2603683 | ||
|
|
73e3478ae3 | ||
|
|
4740e8d4c4 | ||
|
|
fd9c673694 | ||
|
|
d7c194d728 | ||
|
|
f92150c64e | ||
|
|
1f8b267722 | ||
|
|
66f305b747 | ||
|
|
b9accf92a8 | ||
|
|
d33cac774a | ||
|
|
95f25979eb | ||
|
|
929a7c5307 | ||
|
|
ee63b09cbe | ||
|
|
4071975aa4 | ||
|
|
bf56b66b5c | ||
|
|
82202fe88c | ||
|
|
ef14d75534 | ||
|
|
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
|
||||
@@ -27,6 +27,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
|
||||
@@ -27,6 +27,10 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "36 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -15,7 +19,14 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
# The shared workflow creates the draft release, which needs write access to contents.
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -23,9 +34,11 @@ jobs:
|
||||
# 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:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: soundcraft
|
||||
app-title: SoundCraft
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -41,3 +54,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 }}
|
||||
|
||||
@@ -1,11 +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).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
|
||||
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
|
||||
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.
|
||||
@@ -30,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`)
|
||||
@@ -89,3 +139,5 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
|
||||
- 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.
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
Read `AGENTS.md`: it is the entry point for agents in this repo.
|
||||
|
||||
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
|
||||
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
|
||||
before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@AGENTS.md
|
||||
|
||||
Generated
+110
-42
@@ -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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1120,7 +1126,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1130,6 +1136,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1416,6 +1423,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"
|
||||
@@ -1469,6 +1489,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"
|
||||
@@ -4006,6 +4046,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"
|
||||
@@ -4280,7 +4331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -4304,7 +4355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4314,7 +4365,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4326,7 +4377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
@@ -4339,7 +4390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4352,11 +4403,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"log",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4369,7 +4421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4380,7 +4432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4398,12 +4450,13 @@ dependencies = [
|
||||
"soundcraft-video",
|
||||
"soundcraft-vst3-host",
|
||||
"thiserror 2.0.21",
|
||||
"winsafe",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4413,7 +4466,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4429,7 +4482,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4442,7 +4495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4457,7 +4510,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4467,11 +4520,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
@@ -4496,7 +4550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4508,7 +4562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4524,7 +4578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4569,6 +4623,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"
|
||||
@@ -4939,6 +4999,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"
|
||||
@@ -5983,6 +6068,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",
|
||||
@@ -6140,7 +6226,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6372,7 +6458,6 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6387,18 +6472,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
@@ -6460,11 +6533,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"
|
||||
|
||||
+7
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.20.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -50,16 +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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", 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.
|
||||
|
||||
@@ -134,8 +134,8 @@ where things are.
|
||||
playlist per take, input monitoring through the full channel strip, and autosave with recovery.
|
||||
- **Time and tempo:** Bars|Beats, Min:Secs, Timecode (every rate, drop-frame included),
|
||||
Feet+Frames and Samples; tempo and meter maps with a tempo editor; linear, parabolic and
|
||||
S-curve tempo ramps; key signatures and detected chords; Beat Detective and Identify Beat;
|
||||
pitch-preserving Elastic warping.
|
||||
S-curve tempo ramps; key signatures and detected chords; Beat Analysis and Identify Beat;
|
||||
pitch-preserving Elastic Audio warping.
|
||||
- **Audio files:** WAV/BWF/RF64, AIFF/AIFC and FLAC in and out; MP3, Ogg Vorbis, AAC, ALAC, CAF
|
||||
and the audio of MP4/MOV movies in. Any sample rate and bit depth.
|
||||
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with optional dither and
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -168,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -108,9 +108,11 @@ impl eframe::App for App {
|
||||
logger.report_audio_thread();
|
||||
}
|
||||
// The system Quit (⌘Q) ends the process from inside the event loop, so `run_native` never
|
||||
// returns: release the player's plugin instances and shut plugin hosting down here.
|
||||
// returns: release the player's plugin instances and shut plugin hosting down here, and
|
||||
// start the helper of an update that waits for the app to close.
|
||||
self.0.player = None;
|
||||
soundcraft_engine::shutdown_plugin_hosts();
|
||||
self.0.start_pending_update();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
use crate::pcm::{be_u32, be_u64};
|
||||
use crate::{AudioError, Result};
|
||||
|
||||
/// The most `stts` entries a sound track may have. Symphonia finds each packet's timestamp by
|
||||
/// scanning the table from its start, so a table of one-sample runs makes decoding quadratic in
|
||||
/// the packet count. Audio encoders write one run per packet length (a handful of entries);
|
||||
/// this bound keeps the worst case at a few hundred million steps.
|
||||
const MAX_AUDIO_STTS_ENTRIES: u32 = 1 << 14;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Level {
|
||||
Top,
|
||||
@@ -11,7 +17,11 @@ enum Level {
|
||||
Trak,
|
||||
Mdia,
|
||||
Minf,
|
||||
Stbl,
|
||||
/// `audio`: the sample entry is an audio format, so symphonia will decode its packets (whatever the
|
||||
/// handler says: it builds codec parameters from `stsd` alone).
|
||||
Stbl {
|
||||
audio: bool,
|
||||
},
|
||||
Udta,
|
||||
Meta,
|
||||
Ilst,
|
||||
@@ -55,13 +65,20 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
|
||||
(Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?,
|
||||
(Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?,
|
||||
(Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?,
|
||||
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?,
|
||||
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl { audio: entry_is_audio(b, body, inner) })?,
|
||||
(Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?,
|
||||
(Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?,
|
||||
(Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?,
|
||||
(Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?,
|
||||
(Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?,
|
||||
(Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?,
|
||||
(Level::Stbl { audio }, b"stts") => {
|
||||
let fits = table(b, body + 4, 8, inner)?;
|
||||
if audio && be_u32(b, body + 4).is_some_and(|n| n > MAX_AUDIO_STTS_ENTRIES) {
|
||||
return Err(AudioError::Malformed("MP4 audio timing table has more runs than packets could need".into()));
|
||||
}
|
||||
fits
|
||||
}
|
||||
(Level::Stbl { .. }, b"co64") => table(b, body + 4, 8, inner)?,
|
||||
(Level::Stbl { .. }, b"stsc") => table(b, body + 4, 12, inner)?,
|
||||
(Level::Stbl { .. }, b"stco") => table(b, body + 4, 4, inner)?,
|
||||
(Level::Stbl { .. }, b"stsz") => sample_sizes(b, body, inner)?,
|
||||
(Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => {
|
||||
return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind))));
|
||||
}
|
||||
@@ -75,6 +92,38 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The sample entry formats symphonia reads as audio (the list `stsd` is matched against in
|
||||
/// symphonia-format-isomp4 0.6); every other format, video and subtitles included, has no audio packets.
|
||||
const AUDIO_FORMATS: [&[u8; 4]; 18] = [
|
||||
b"mp4a", b"alac", b"ac-3", b"ec-3", b"fLaC", b"Opus", b".mp3", b"lpcm", b"wave", b"alaw", b"ulaw", b"raw ", b"sowt", b"twos", b"in24", b"in32",
|
||||
b"fl32", b"fl64",
|
||||
];
|
||||
|
||||
/// Whether the `stbl` atom whose children lie in `pos..end` describes audio: its `stsd` may come before
|
||||
/// or after the timing tables, so the children are walked once more for it. Symphonia keeps the last
|
||||
/// `stsd` it reads, so the last one decides.
|
||||
fn entry_is_audio(b: &[u8], mut pos: usize, end: usize) -> bool {
|
||||
let mut audio = false;
|
||||
while pos < end {
|
||||
let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return audio };
|
||||
let head = if size == 1 { 16 } else { 8 };
|
||||
if kind == b"stsd" {
|
||||
// Version and flags, the entry count, then the first entry's size and format.
|
||||
audio = b.get(pos + head + 12..pos + head + 16).is_some_and(|f| AUDIO_FORMATS.iter().any(|a| a.as_slice() == f));
|
||||
}
|
||||
let len = match size {
|
||||
0 => return audio,
|
||||
1 => be_u64(b, pos + 8).unwrap_or(0),
|
||||
n => u64::from(n),
|
||||
};
|
||||
match usize::try_from(len).ok().filter(|&n| n >= head).and_then(|n| pos.checked_add(n)) {
|
||||
Some(next) => pos = next,
|
||||
None => return audio,
|
||||
}
|
||||
}
|
||||
audio
|
||||
}
|
||||
|
||||
/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for
|
||||
/// all of them first. False if they run past the table's atom (`end`), where symphonia stops.
|
||||
fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result<bool> {
|
||||
|
||||
@@ -22,6 +22,11 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat
|
||||
/// takes its frame length from the file (encoders use 4096).
|
||||
const MAX_PACKET_FRAMES: u64 = 1 << 20;
|
||||
|
||||
/// The most samples (frames × channels) a packet may declare: the PCM decoder allocates that
|
||||
/// many for its output up front, so a CAF with thousands of channels and a long packet could
|
||||
/// otherwise claim gigabytes before a sample is decoded (64 MiB of f32).
|
||||
const MAX_PACKET_SAMPLES: u64 = 1 << 24;
|
||||
|
||||
/// How far [`first_marker`] looks for the first format marker: the same 1 MiB a default symphonia
|
||||
/// probe scans.
|
||||
const MAX_PREFIX_SCAN: usize = 1 << 20;
|
||||
@@ -508,7 +513,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
}
|
||||
let params = audio_params(track).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
|
||||
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
|
||||
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
|
||||
let ch = u64::from(info.channels);
|
||||
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES || n.saturating_mul(ch) > MAX_PACKET_SAMPLES)
|
||||
{
|
||||
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
|
||||
}
|
||||
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &decoder_options()).map_err(map_err)?;
|
||||
@@ -531,6 +538,13 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
if packet.track_id != track_id {
|
||||
continue;
|
||||
}
|
||||
// The declared frames per packet are not the packet's size: a container supplies that on its
|
||||
// own, and the PCM decoder sizes its output by the bytes it is given.
|
||||
if let Some(bytes_per_sample) = params.bits_per_coded_sample.map(|b| u64::from(b / 8)).filter(|&n| n > 0)
|
||||
&& packet.data.len() as u64 / bytes_per_sample > MAX_PACKET_SAMPLES
|
||||
{
|
||||
return Err(AudioError::Malformed("a packet holds far more samples than any codec uses".into()));
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(d) => d,
|
||||
Err(SymError::DecodeError(m)) => {
|
||||
|
||||
@@ -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])]);
|
||||
@@ -312,7 +321,7 @@ fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec<u8>) -> Vec<u8>) -
|
||||
};
|
||||
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
|
||||
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
|
||||
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
|
||||
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| i.wrapping_mul(40).to_le_bytes()).collect();
|
||||
[ftyp, moov(offset), atom(b"mdat", &pcm)].concat()
|
||||
}
|
||||
|
||||
@@ -364,6 +373,63 @@ fn packets_longer_than_any_codec_uses_are_refused() {
|
||||
for entry in [lpcm(1 << 21), alac(1 << 21)] {
|
||||
assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets")));
|
||||
}
|
||||
// Frames and channels multiply into the decoder's packet buffer; an ordinary stereo stream
|
||||
// with long packets stays well inside the bound.
|
||||
let wide = |channels: u32, frames: u32| {
|
||||
let fields = [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[channels, 0x7f00_0000, 16, 4, 2 * channels, frames])].concat();
|
||||
sample_entry(b"lpcm", 2, &fields, &[])
|
||||
};
|
||||
assert!(decode(&mp4(480, &wide(2, 1 << 13), |_, a| a), None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_audio_timing_table_with_a_run_per_sample_is_bounded() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
// One stts run per sample: symphonia scans the runs from the start for every packet.
|
||||
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
|
||||
let frames = 1 << 14;
|
||||
let (_, buf) = decode(&mp4(frames, &pcm, |k, a| if k == b"stts" { runs(frames) } else { a }), None).unwrap();
|
||||
assert_eq!(buf.frames(), frames as usize, "a table at the bound still decodes");
|
||||
let f = mp4(frames + 1, &pcm, |k, a| if k == b"stts" { runs(frames + 1) } else { a });
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "one run past the bound is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_audio_timing_bound_follows_the_sample_entry_not_the_handler() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
|
||||
// an audio sample entry under a video handler is still decoded as audio by symphonia
|
||||
let video_handler = atom(b"hdlr", &[0u32, 0, u32::from_be_bytes(*b"vide"), 0, 0, 0, 0].map(u32::to_be_bytes).concat());
|
||||
let frames = (1 << 14) + 1;
|
||||
let f = mp4(frames, &pcm, |k, a| match k {
|
||||
b"stts" => runs(frames),
|
||||
b"hdlr" => video_handler.clone(),
|
||||
_ => a,
|
||||
});
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "the handler does not exempt an audio entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_sample_entry_decides_whether_the_timing_bound_applies() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
let video = sample_entry(b"avc1", 0, &[], &[]);
|
||||
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
|
||||
let audio_stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &pcm].concat());
|
||||
let frames = (1 << 14) + 1;
|
||||
// A video `stsd` first and an audio one after it: symphonia keeps the last, so it decodes audio.
|
||||
let f = mp4(frames, &video, |k, a| if k == b"stts" { [audio_stsd.clone(), runs(frames)].concat() } else { a });
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "the later audio entry counts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pcm_packet_longer_than_its_declared_frames_is_refused_before_decoding() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
// one `stsz` sample of 2 * 2^24 + 2 bytes (just over the packet bound) in a file that holds it
|
||||
let big = 2 * (1usize << 24) + 2;
|
||||
let stsz = atom(b"stsz", &[0u32, big as u32, 1].map(u32::to_be_bytes).concat());
|
||||
let mut f = mp4(1, &pcm, |k, a| if k == b"stsz" { stsz.clone() } else { a });
|
||||
f.resize(f.len() + big, 0);
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("packet holds far more samples")));
|
||||
}
|
||||
|
||||
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
|
||||
|
||||
@@ -310,6 +310,36 @@ fn links_out_of_the_roots_are_refused() {
|
||||
ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn exports_refuse_hard_links_without_changing_either_name() {
|
||||
let f = fixture("hard-links");
|
||||
let midi = f.write.join("linked.mid");
|
||||
let preset = f.write.join("linked.json");
|
||||
std::fs::hard_link(f.outside.join("secret.mid"), &midi).unwrap();
|
||||
std::fs::hard_link(f.outside.join("secret.json"), &preset).unwrap();
|
||||
let mut srv = demo_server(Some(&f.read), Some(&f.write));
|
||||
let before = listing(&f.write);
|
||||
|
||||
// Refusal is the safe alternative allowed by #1359: neither the outside inode nor its
|
||||
// in-root alias may be truncated, and no replacement or temporary file may be left behind.
|
||||
refused(&mut srv, "file.export_midi", json!({"path": s(&midi)}), "hard-linked");
|
||||
refused(&mut srv, "track.save_preset", json!({"path": s(&preset), "tracks": ["Kick"]}), "hard-linked");
|
||||
for path in [&midi, &preset, &f.outside.join("secret.mid"), &f.outside.join("secret.json")] {
|
||||
assert_eq!(std::fs::read(path).unwrap(), SECRET.as_bytes());
|
||||
}
|
||||
assert_eq!(listing(&f.write), before);
|
||||
|
||||
// The error offers a new export path; both commands must still work at ordinary paths.
|
||||
let fresh_midi = f.write.join("fresh.mid");
|
||||
ok(&mut srv, "file.export_midi", json!({"path": s(&fresh_midi)}));
|
||||
assert!(std::fs::read(fresh_midi).unwrap().starts_with(b"MThd"));
|
||||
let fresh_preset = f.write.join("fresh.json");
|
||||
ok(&mut srv, "track.save_preset", json!({"path": s(&fresh_preset), "tracks": ["Kick"]}));
|
||||
let preset_json: Value = serde_json::from_slice(&std::fs::read(fresh_preset).unwrap()).unwrap();
|
||||
assert_eq!(preset_json["name"], "Kick");
|
||||
}
|
||||
|
||||
/// A session file is hostile input too: media it names outside the read root is not loaded,
|
||||
/// and the refusal is listed with the other missing media.
|
||||
#[test]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+214
-16
@@ -10,6 +10,10 @@ use std::sync::atomic::Ordering;
|
||||
const MAX_BLOCK: usize = 1 << 16;
|
||||
/// Most channels a host strip may ask for.
|
||||
const MAX_CHANNELS: usize = 64;
|
||||
/// Queue slots that only releases from `note_off` may take: one per key, so a queue that
|
||||
/// parameter changes, note-ons or other MIDI filled still has room to release every held key on
|
||||
/// a reset (#1464).
|
||||
const RELEASE_RESERVE: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum NoteMode {
|
||||
@@ -79,14 +83,104 @@ impl ClapPlugin {
|
||||
self.queue.retain(|e| !matches!(e, Event::Param { .. }));
|
||||
}
|
||||
|
||||
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
|
||||
/// allocates on the audio thread).
|
||||
/// Queues an event other than a note release; see [`push_event`].
|
||||
fn push(&mut self, e: Event) {
|
||||
if self.queue.len() >= self.queue.capacity() {
|
||||
return;
|
||||
push_event(&mut self.queue, e, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
|
||||
/// allocates on the audio thread). Only a `release` may take the last [`RELEASE_RESERVE`] slots.
|
||||
/// Returns whether the event was queued.
|
||||
fn push_event(queue: &mut Vec<Event>, e: Event, release: bool) -> bool {
|
||||
let room = if release { queue.capacity() } else { queue.capacity().saturating_sub(RELEASE_RESERVE) };
|
||||
if queue.len() >= room {
|
||||
return false;
|
||||
}
|
||||
let pos = queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
|
||||
queue.insert(pos, e);
|
||||
true
|
||||
}
|
||||
|
||||
/// Queues the release of `key` at `time`. The key stays held when the queue has no room, so a
|
||||
/// later reset still releases it (#1464).
|
||||
fn queue_release(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode, time: u32, key: u8) {
|
||||
if let Some(e) = release_event(notes, time, key)
|
||||
&& push_event(queue, e, true)
|
||||
&& let Some(h) = held.get_mut(usize::from(key))
|
||||
{
|
||||
*h = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The release of `key` in the note port's dialect (`None` without a note port).
|
||||
fn release_event(notes: NoteMode, time: u32, key: u8) -> Option<Event> {
|
||||
match notes {
|
||||
NoteMode::Clap => Some(Event::NoteOff { time, key }),
|
||||
NoteMode::Midi => Some(Event::Midi { time, data: [0x80, key, 0] }),
|
||||
NoteMode::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The queue after a reset: [`keep_releases`], then a release at the start of the next block for
|
||||
/// every key still `held` that has none yet, so each key is released once. Clears `held`.
|
||||
///
|
||||
/// It never runs out of room: events other than `note_off` releases fill at most
|
||||
/// `capacity - RELEASE_RESERVE` slots, and the releases left are one per key.
|
||||
fn release_on_reset(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode) {
|
||||
keep_releases(queue);
|
||||
for e in queue.iter() {
|
||||
let key = match *e {
|
||||
Event::NoteOff { key, .. } => Some(key),
|
||||
// Channel 1, where `note_off` sends them.
|
||||
Event::Midi { data: [status, key, velocity], .. } if status & 0x0F == 0 && is_midi_note_off(status, velocity) => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(h) = key.and_then(|k| held.get_mut(usize::from(k))) {
|
||||
*h = false;
|
||||
}
|
||||
let pos = self.queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
|
||||
self.queue.insert(pos, e);
|
||||
}
|
||||
for key in 0..128u8 {
|
||||
if held.get(usize::from(key)).copied().unwrap_or(false)
|
||||
&& let Some(e) = release_event(notes, 0, key)
|
||||
{
|
||||
let _ = push_event(queue, e, true);
|
||||
}
|
||||
}
|
||||
*held = [false; 128];
|
||||
}
|
||||
|
||||
/// What a reset keeps of the pending events: parameter changes, and the releases (CLAP note-offs
|
||||
/// and MIDI note-offs, one per channel and key), all moved to the start of the next block. Note-ons
|
||||
/// and other MIDI belong to the position being left. One release per key leaves room in the queue
|
||||
/// for `all_notes_off`.
|
||||
fn keep_releases(queue: &mut Vec<Event>) {
|
||||
let mut clap_off = [false; 128];
|
||||
let mut midi_off = [[false; 128]; 16];
|
||||
queue.retain(|e| match *e {
|
||||
Event::Param { .. } => true,
|
||||
Event::NoteOff { key, .. } => first_release(clap_off.get_mut(usize::from(key))),
|
||||
Event::Midi { data: [status, key, velocity], .. } if is_midi_note_off(status, velocity) => {
|
||||
first_release(midi_off.get_mut(usize::from(status & 0x0F)).and_then(|c| c.get_mut(usize::from(key))))
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
for e in queue.iter_mut() {
|
||||
e.set_time(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a key's release as seen; true only the first time (false for a key out of range).
|
||||
fn first_release(seen: Option<&mut bool>) -> bool {
|
||||
seen.is_some_and(|s| !std::mem::replace(s, true))
|
||||
}
|
||||
|
||||
/// A MIDI 1.0 note-off: status 0x8n, or a note-on (0x9n) with velocity 0.
|
||||
fn is_midi_note_off(status: u8, velocity: u8) -> bool {
|
||||
match status & 0xF0 {
|
||||
0x80 => true,
|
||||
0x90 => velocity == 0,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,10 +272,17 @@ 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 { .. }));
|
||||
self.held = [false; 128];
|
||||
// Keep pending releases and release whatever is still held: CLAP asks `reset` to kill
|
||||
// voices, but some plugins' doesn't (Surge XT's), and they would sound on with nothing
|
||||
// left to release them (#1464).
|
||||
release_on_reset(&mut self.queue, &mut self.held, self.notes);
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool {
|
||||
@@ -282,14 +383,7 @@ impl Plugin for ClapPlugin {
|
||||
return;
|
||||
}
|
||||
let time = u32::try_from(offset).unwrap_or(u32::MAX);
|
||||
match self.notes {
|
||||
NoteMode::Clap => self.push(Event::NoteOff { time, key: note }),
|
||||
NoteMode::Midi => self.push(Event::Midi { time, data: [0x80, note, 0] }),
|
||||
NoteMode::None => return,
|
||||
}
|
||||
if let Some(h) = self.held.get_mut(note as usize) {
|
||||
*h = false;
|
||||
}
|
||||
queue_release(&mut self.queue, &mut self.held, self.notes, time, note);
|
||||
}
|
||||
|
||||
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
|
||||
@@ -378,3 +472,107 @@ impl PluginEditor for ClapEditor {
|
||||
self.link.host().dirty.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A reset keeps parameter changes and one release per key, at the start of the next block,
|
||||
/// and drops note-ons and other MIDI.
|
||||
#[test]
|
||||
fn reset_keeps_params_and_one_release_per_key() {
|
||||
let mut q = vec![
|
||||
Event::Param { time: 0, id: 4, value: 0.5 },
|
||||
Event::NoteOn { time: 3, key: 60, velocity: 1.0 },
|
||||
Event::NoteOff { time: 5, key: 60 },
|
||||
Event::NoteOff { time: 9, key: 60 },
|
||||
Event::NoteOff { time: 12, key: 64 },
|
||||
Event::Midi { time: 20, data: [0x90, 62, 90] },
|
||||
Event::Midi { time: 21, data: [0xB0, 64, 127] },
|
||||
Event::Midi { time: 22, data: [0x80, 62, 0] },
|
||||
Event::Midi { time: 23, data: [0x80, 62, 40] },
|
||||
Event::Midi { time: 24, data: [0x83, 62, 0] },
|
||||
Event::Midi { time: 25, data: [0x91, 67, 0] },
|
||||
Event::Midi { time: 26, data: [0x80, 200, 0] },
|
||||
];
|
||||
keep_releases(&mut q);
|
||||
assert_eq!(
|
||||
q,
|
||||
vec![
|
||||
Event::Param { time: 0, id: 4, value: 0.5 },
|
||||
Event::NoteOff { time: 0, key: 60 },
|
||||
Event::NoteOff { time: 0, key: 64 },
|
||||
Event::Midi { time: 0, data: [0x80, 62, 0] },
|
||||
Event::Midi { time: 0, data: [0x83, 62, 0] },
|
||||
Event::Midi { time: 0, data: [0x91, 67, 0] },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A queue that parameter changes filled still releases every held key on a reset, once
|
||||
/// each, and keeps the parameter changes.
|
||||
#[test]
|
||||
fn a_full_queue_still_releases_every_held_key_on_reset() {
|
||||
for notes in [NoteMode::Clap, NoteMode::Midi] {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
for id in 0..EVENT_CAP as u32 {
|
||||
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP - RELEASE_RESERVE, "parameter changes leave the reserve free");
|
||||
// Keys 0..4 already have a release queued, and are held again after it.
|
||||
for key in 0..4u8 {
|
||||
let _ = push_event(&mut q, release_event(notes, 7, key).unwrap(), true);
|
||||
}
|
||||
let mut held = [true; 128];
|
||||
release_on_reset(&mut q, &mut held, notes);
|
||||
assert_eq!(held, [false; 128]);
|
||||
assert_eq!(q.iter().filter(|e| matches!(e, Event::Param { .. })).count(), EVENT_CAP - RELEASE_RESERVE, "{notes:?}");
|
||||
for key in 0..128u8 {
|
||||
let want = release_event(notes, 0, key).unwrap();
|
||||
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP);
|
||||
}
|
||||
}
|
||||
|
||||
/// A release that finds the queue full (parameter changes plus duplicate releases) leaves its
|
||||
/// key held, so the reset that follows still releases it, as the mix's `all_notes_off`
|
||||
/// then `reset` does on Stop.
|
||||
#[test]
|
||||
fn a_release_the_full_queue_drops_is_sent_by_the_next_reset() {
|
||||
for notes in [NoteMode::Clap, NoteMode::Midi] {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
let mut held = [false; 128];
|
||||
for id in 0..EVENT_CAP as u32 {
|
||||
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
|
||||
}
|
||||
// Key 0 pressed and released over and over fills the reserve with its releases.
|
||||
for t in 0..RELEASE_RESERVE as u32 {
|
||||
held[0] = true;
|
||||
queue_release(&mut q, &mut held, notes, t, 0);
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP, "{notes:?}: the queue is full");
|
||||
held[5] = true;
|
||||
queue_release(&mut q, &mut held, notes, 0, 5);
|
||||
assert!(held[5], "{notes:?}: a release that was not queued leaves its key held");
|
||||
release_on_reset(&mut q, &mut held, notes);
|
||||
for key in [0u8, 5] {
|
||||
let want = release_event(notes, 0, key).unwrap();
|
||||
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
|
||||
}
|
||||
assert_eq!(held, [false; 128]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A queue full of releases still has room to release every held key after a reset.
|
||||
#[test]
|
||||
fn reset_keeps_room_to_release_every_held_key() {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
for i in 0..EVENT_CAP {
|
||||
q.push(Event::NoteOff { time: i as u32, key: (i % 4) as u8 });
|
||||
}
|
||||
keep_releases(&mut q);
|
||||
let want: Vec<Event> = (0..4u8).map(|key| Event::NoteOff { time: 0, key }).collect();
|
||||
assert_eq!(q, want, "one release per key remains");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
//! (stepped enum A/B/C), hidden param 9, latency 3 samples.
|
||||
//! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal
|
||||
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
|
||||
//! MIDI CC 7 (volume) sets the constant to its value / 127.
|
||||
//! MIDI CC 7 (volume) sets the constant to its value / 127. `reset` leaves the note sounding.
|
||||
//!
|
||||
//! - `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) });
|
||||
}
|
||||
@@ -157,9 +166,9 @@ unsafe extern "C" fn stop_processing(p: *const clap_plugin) {
|
||||
unsafe { fx(p) }.processing = false;
|
||||
}
|
||||
|
||||
unsafe extern "C" fn reset(p: *const clap_plugin) {
|
||||
unsafe { fx(p) }.level = 0.0;
|
||||
}
|
||||
/// Leaves the synth's note sounding. CLAP asks `reset` to kill voices, but some synths' doesn't
|
||||
/// (Surge XT's), so the host must send the releases itself.
|
||||
unsafe extern "C" fn reset(_p: *const clap_plugin) {}
|
||||
|
||||
fn apply(f: &mut Fx, h: &clap_event_header) {
|
||||
if h.space_id != CLAP_CORE_EVENT_SPACE_ID {
|
||||
@@ -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 {
|
||||
|
||||
@@ -109,6 +109,48 @@ fn synth_plays_notes_at_their_offsets() {
|
||||
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
|
||||
}
|
||||
|
||||
/// Renders one 64-frame stereo block of `p` and returns its left channel.
|
||||
fn render_block(p: &mut dyn soundcraft_dsp::Plugin) -> Vec<f32> {
|
||||
let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]];
|
||||
p.process(&mut io, 64);
|
||||
io.swap_remove(0)
|
||||
}
|
||||
|
||||
/// Stop, Play and Locate run `all_notes_off` then `reset`: the releases reach a synth whose own
|
||||
/// reset leaves its voices sounding, and a later block is silent (#1464).
|
||||
#[test]
|
||||
fn reset_delivers_the_releases_of_held_notes() {
|
||||
fixture_dir();
|
||||
let mut p = soundcraft_clap_host::create(SYNTH).unwrap();
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
let silent = |b: &[f32]| b.iter().all(|x| x.abs() < 1e-6);
|
||||
|
||||
// As the mix engine does on Stop: release the held note, then reset.
|
||||
p.note_on(0, 60, 127);
|
||||
assert!(render_block(p.as_mut()).iter().all(|&x| (x - 1.0).abs() < 1e-6), "the note sounds");
|
||||
p.all_notes_off();
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "all_notes_off's release was dropped by reset");
|
||||
|
||||
// A reset alone releases what is still held.
|
||||
p.note_on(0, 61, 127);
|
||||
render_block(p.as_mut());
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "reset did not release the held note");
|
||||
|
||||
// A release queued for a block that never ran is still delivered.
|
||||
p.note_on(0, 62, 127);
|
||||
render_block(p.as_mut());
|
||||
p.note_off(30, 62);
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "the pending release was dropped by reset");
|
||||
|
||||
// A note-on queued for the position being left is dropped, not played after the reset.
|
||||
p.note_on(10, 63, 127);
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "a note-on from before the reset sounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
fixture_dir();
|
||||
@@ -130,6 +172,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 +257,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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction), Rectifier and
|
||||
//! Harmonic effects: Saturator (tanh), Bit Crusher (bit/sample-rate reduction), Rectifier and
|
||||
//! Subharmonic (octave-down bass synthesis).
|
||||
|
||||
use crate::biquad::{Biquad, FilterType};
|
||||
@@ -134,8 +134,8 @@ static LOFI_PARAMS: [ParamInfo; 4] = [
|
||||
|
||||
pub static LOFI_INFO: PluginInfo = PluginInfo {
|
||||
id: "lofi",
|
||||
name: "Lo-Fi",
|
||||
short_name: "LoFi",
|
||||
name: "Bit Crusher",
|
||||
short_name: "Crush",
|
||||
category: Category::Harmonic,
|
||||
params: &LOFI_PARAMS,
|
||||
is_instrument: false,
|
||||
|
||||
@@ -25,6 +25,9 @@ log.workspace = true
|
||||
zip.workspace = true
|
||||
craft-docpath.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winsafe = { version = "0.0.29", features = ["kernel"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
|
||||
+189
-11
@@ -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()))?;
|
||||
@@ -290,9 +306,7 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
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 });
|
||||
}
|
||||
_ => {
|
||||
@@ -304,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.
|
||||
@@ -425,6 +454,32 @@ pub fn create_folder(dir: &Path) -> std::io::Result<()> {
|
||||
builder.create(dir)
|
||||
}
|
||||
|
||||
/// A new folder directly in `parent`, named `{prefix}-` and 32 random hex digits. It is made
|
||||
/// exclusively (it is created by this call or the call fails), so a folder or link another account
|
||||
/// put there first is never used, and the name cannot be guessed ahead. On Unix it is owner-only
|
||||
/// (`0o700`); on other systems it has the mode `parent` gives it.
|
||||
pub fn create_unique_folder(parent: &Path, prefix: &str) -> std::io::Result<PathBuf> {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
// One plain name: a prefix like `../x` would make a sibling of `parent`, not a child.
|
||||
let mut parts = Path::new(prefix).components();
|
||||
if !matches!((parts.next(), parts.next()), (Some(std::path::Component::Normal(_)), None)) || prefix.contains(['/', '\\']) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the folder prefix must be a single name"));
|
||||
}
|
||||
for _ in 0..16 {
|
||||
let random = |_| std::collections::hash_map::RandomState::new().build_hasher().finish();
|
||||
let dir = parent.join(format!("{prefix}-{:016x}{:016x}", random(0), random(1)));
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
match builder.create(&dir) {
|
||||
Ok(()) => return Ok(dir),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no unused folder name"))
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
f.write_all(bytes)?;
|
||||
@@ -447,10 +502,50 @@ fn io_error(path: &Path, err: std::io::Error) -> EngineError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::io::open_session;
|
||||
|
||||
#[test]
|
||||
fn unique_folders_are_new_and_owner_only() {
|
||||
let parent = std::env::temp_dir().join(format!("soundcraft-unique-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&parent);
|
||||
std::fs::create_dir_all(&parent).unwrap();
|
||||
let (a, b) = (create_unique_folder(&parent, "T").unwrap(), create_unique_folder(&parent, "T").unwrap());
|
||||
assert_ne!(a, b);
|
||||
assert!(a.is_dir() && a.parent() == Some(parent.as_path()));
|
||||
assert_eq!(a.file_name().unwrap().len(), "T-".len() + 32);
|
||||
#[cfg(unix)]
|
||||
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&a).unwrap().permissions()) & 0o777, 0o700);
|
||||
assert!(create_unique_folder(&parent.join("missing"), "T").is_err(), "no parents are made");
|
||||
for bad in ["../outside", "a/b", "a\\b", "..", ".", "", "/abs"] {
|
||||
assert!(create_unique_folder(&parent, bad).is_err(), "{bad:?} is not a single name");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&parent);
|
||||
}
|
||||
|
||||
use serde_json::json;
|
||||
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);
|
||||
@@ -507,6 +602,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");
|
||||
@@ -673,6 +787,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
|
||||
|
||||
@@ -65,13 +65,50 @@ pub const ALIASES: &[(&str, &str)] = &[
|
||||
("View > Rulers > Chord Symbols", "view.ruler"),
|
||||
];
|
||||
|
||||
// Name migration fallbacks run after explicit UI mappings so opening a dialog
|
||||
// keeps its original command and enablement rather than becoming a direct edit.
|
||||
const LEGACY_LABEL_ALIASES: &[(&str, &str)] = &[
|
||||
// Historical catalog paths remain valid when their display names change.
|
||||
("Clip > Elastic Properties", "clip.elastic_properties"),
|
||||
("Options > QuickPunch", "options.quickpunch"),
|
||||
("Options > TrackPunch", "options.trackpunch"),
|
||||
("View > Transport > Ableton Link", "view.transport_ableton_link"),
|
||||
("Event > Beat Detective", "event.beat_detective"),
|
||||
("File > Export > Sibelius...", "file.export_sibelius"),
|
||||
("File > Send To... > Sibelius", "file.send_to_sibelius"),
|
||||
];
|
||||
|
||||
/// Catalog leaves naming features we do not have. They keep the incumbent's place — and with it
|
||||
/// the parity floor — under a descriptive label, but they are never interactive: the UI draws them
|
||||
/// disabled with a "Not available yet." hover until the behaviour exists, as Preferences does for
|
||||
/// settings without a backend. Naming the place does not claim the protocol, plug-in format or
|
||||
/// processor behind it.
|
||||
pub const PLACEHOLDERS: &[&str] = &[
|
||||
"View > Transport > Ableton Link",
|
||||
"Track > ARA Plugins > Melodyne",
|
||||
"Clip > ARA Plugins > Melodyne",
|
||||
"View > Mix Window Views > HEAT",
|
||||
"Options > Activate HEAT",
|
||||
];
|
||||
|
||||
/// Whether `path` is a catalog leaf kept as a placeholder for a feature we do not have.
|
||||
pub fn is_placeholder(path: &str) -> bool {
|
||||
PLACEHOLDERS.contains(&path)
|
||||
}
|
||||
|
||||
pub fn catalog() -> Vec<&'static str> {
|
||||
MENUS.lines().map(str::trim).filter(|l| !l.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Command id implementing a catalog entry, if any.
|
||||
pub fn implemented_by(entry: &str, extra: &[(&str, &str)]) -> Option<String> {
|
||||
if let Some((_, id)) = ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()).chain(extra.iter()).find(|(p, _)| *p == entry) {
|
||||
if let Some((_, id)) = ALIASES
|
||||
.iter()
|
||||
.chain(crate::catalog_more::ALIASES_MORE.iter())
|
||||
.chain(extra.iter())
|
||||
.chain(LEGACY_LABEL_ALIASES.iter())
|
||||
.find(|(p, _)| *p == entry)
|
||||
{
|
||||
return Some((*id).to_string());
|
||||
}
|
||||
let parts: Vec<&str> = entry.split(" > ").collect();
|
||||
@@ -116,7 +153,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn aliases_point_at_real_commands() {
|
||||
for (path, id) in ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()) {
|
||||
for (path, id) in ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()).chain(LEGACY_LABEL_ALIASES.iter()) {
|
||||
assert!(crate::find_command(id).is_some(), "{path} → unknown {id}");
|
||||
assert!(catalog().contains(path), "alias for unknown catalog entry {path}");
|
||||
}
|
||||
@@ -131,4 +168,40 @@ mod tests {
|
||||
assert!(pct >= 25.0, "engine parity regressed: {pct}%");
|
||||
assert!(done >= 396, "engine parity regressed: {done} menu items");
|
||||
}
|
||||
|
||||
/// A placeholder must keep its catalog place (so the floor holds and the menu still lists it)
|
||||
/// and must not read as the incumbent's own name: the label says what it is, not who makes it.
|
||||
#[test]
|
||||
fn placeholders_keep_their_place_with_honest_labels() {
|
||||
let entries = catalog();
|
||||
for path in PLACEHOLDERS {
|
||||
assert!(entries.contains(path), "placeholder for an unknown catalog entry: {path}");
|
||||
assert!(is_placeholder(path), "{path}");
|
||||
let leaf = path.rsplit_once(" > ").map_or(*path, |(_, leaf)| leaf);
|
||||
let label = crate::feature_names::display_label(leaf);
|
||||
assert_ne!(label, leaf, "{path} still shows the incumbent's name");
|
||||
for retired in ["ableton", "melodyne", "heat"] {
|
||||
assert!(!label.to_lowercase().contains(retired), "{path}: {label}");
|
||||
}
|
||||
}
|
||||
assert!(!is_placeholder("Options > Loop Record"), "an implemented item is not a placeholder");
|
||||
assert!(!is_placeholder("View > Transport > MIDI Controls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_labels_keep_legacy_catalog_dispatch() {
|
||||
let ui = [("Event > Beat Detective", "window.beat_detective")];
|
||||
assert_eq!(implemented_by("Event > Beat Detective", &ui).as_deref(), Some("window.beat_detective"));
|
||||
for (path, id, label) in [
|
||||
("Options > QuickPunch", "options.quickpunch", "Punch Recording"),
|
||||
("Options > TrackPunch", "options.trackpunch", "TrackPunch"),
|
||||
("View > Transport > Ableton Link", "view.transport_ableton_link", "Tempo Sync"),
|
||||
("Event > Beat Detective", "event.beat_detective", "Beat Analysis"),
|
||||
("File > Export > Sibelius...", "file.export_sibelius", "MusicXML..."),
|
||||
("File > Send To... > Sibelius", "file.send_to_sibelius", "MusicXML"),
|
||||
] {
|
||||
assert_eq!(implemented_by(path, &[]).as_deref(), Some(id));
|
||||
assert_eq!(crate::find_command(id).unwrap().label, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,23 +87,30 @@ fn decode_group_audio(
|
||||
cmd_id: &str,
|
||||
) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
|
||||
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
|
||||
// The folder as the file system knows it, so a link among the group's files can't lead out.
|
||||
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
|
||||
let root = crate::io::canonical_path(dir)?;
|
||||
for (old, rel) in audio_entries {
|
||||
let full = dir.join(rel);
|
||||
let escapes = |rel: &str| EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}"));
|
||||
if !full.starts_with(dir)
|
||||
|| Path::new(rel)
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_)))
|
||||
{
|
||||
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
return Err(escapes(rel));
|
||||
}
|
||||
let key = crate::io::canonical_path(&full)?;
|
||||
if !key.starts_with(&root) {
|
||||
return Err(escapes(rel));
|
||||
}
|
||||
let key = crate::io::file_key(&full);
|
||||
let n = match index.get(&key) {
|
||||
Some(n) => *n,
|
||||
None => {
|
||||
let bytes = crate::io::read_file(&full)?;
|
||||
let bytes = crate::io::read_file_inside(&full, &root)?;
|
||||
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
|
||||
let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES)
|
||||
.ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?;
|
||||
.map_err(|r| EngineError::Io(format!("{rel}: {}", r.message("too large to import"))))?;
|
||||
decoded.push((rel.clone(), buf));
|
||||
index.insert(key, decoded.len() - 1);
|
||||
decoded.len() - 1
|
||||
@@ -268,9 +275,59 @@ mod tests {
|
||||
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:?}");
|
||||
// A link inside the folder that leads out of it is refused once resolved.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let outside = std::env::temp_dir().join(format!("sc-grp-outside-{}.wav", std::process::id()));
|
||||
std::fs::copy(dir.join("Wait... what_0.wav"), &outside).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.join("link.wav")).unwrap();
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!("link.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(..))), "links out of the folder are refused: {r:?}");
|
||||
let _ = std::fs::remove_file(&outside);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Under a read root, a link in the group folder to a folder outside the root is refused the same way
|
||||
/// whether or not the file asked for exists there, so the group cannot be used to probe for names.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_out_of_the_read_root_are_refused_alike_for_existing_and_missing_files() {
|
||||
let base = std::env::temp_dir().join(format!("sc-grp-root-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (dir, outside) = (base.join("group"), base.join("outside"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
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();
|
||||
let exported = file["audio"].as_object().unwrap().values().next().unwrap().as_str().unwrap().to_string();
|
||||
std::fs::copy(dir.join(&exported), outside.join("real.wav")).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.join("out")).unwrap();
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&dir], &[]).unwrap().unwrap();
|
||||
let mut refusals = Vec::new();
|
||||
for target in ["out/real.wav", "out/missing.wav"] {
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!(target);
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = crate::file_access::confine(Some(&roots), || {
|
||||
e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}))
|
||||
});
|
||||
refusals.push(r.unwrap_err().to_string());
|
||||
}
|
||||
assert!(refusals[0].contains("outside the read root"), "{}", refusals[0]);
|
||||
assert_eq!(refusals[0].replace("real.wav", "X"), refusals[1].replace("missing.wav", "X"), "existing and missing names are refused alike");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_clips_carry_no_auto_rename_scheme() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
|
||||
|
||||
@@ -347,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}`")));
|
||||
}
|
||||
@@ -228,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();
|
||||
|
||||
@@ -425,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, &[]);
|
||||
|
||||
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
shuffle_lock
|
||||
),
|
||||
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
|
||||
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10, F6+F7, F7+F8, Cmd+6-7"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
|
||||
let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?;
|
||||
e.session_mut().edit.tool = t;
|
||||
Ok(json!({"tool": t}))
|
||||
@@ -301,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;
|
||||
@@ -328,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")?;
|
||||
@@ -335,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;
|
||||
@@ -668,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 {
|
||||
@@ -956,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() {
|
||||
@@ -978,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> {
|
||||
|
||||
@@ -154,6 +154,7 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
|
||||
for t in ids {
|
||||
crate::edit::separate_all_at(s, t, r.start);
|
||||
crate::edit::insert_silence(s, t, r);
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
for l in &mut tr.automation {
|
||||
@@ -185,6 +186,8 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
|
||||
for t in ids {
|
||||
crate::edit::separate_all_at(s, t, r.start);
|
||||
crate::edit::separate_all_at(s, t, r.end);
|
||||
crate::edit::clear_range(s, t, r, false);
|
||||
crate::edit::shift_from(s, t, r.end, -r.len());
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
|
||||
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"event.beat_detective",
|
||||
"Beat Detective",
|
||||
"Beat Analysis",
|
||||
["Event"],
|
||||
None,
|
||||
"{tracks?, start?, end?, sensitivity?: 0.5, separate?: false, set_tempo?: false}",
|
||||
|
||||
@@ -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 {
|
||||
@@ -92,9 +94,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
|
||||
let r = range_param(e, "file.bounce_stems", p)?;
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
let format = str_param(p, "format")
|
||||
.and_then(soundcraft_audio_io::FileFormat::from_write_extension)
|
||||
.unwrap_or(soundcraft_audio_io::FileFormat::Wav);
|
||||
let format_name = match p.get("format") {
|
||||
None => "wav",
|
||||
Some(value) => value.as_str().ok_or_else(|| bad("file.bounce_stems", "`format` must be a string"))?,
|
||||
};
|
||||
let format = soundcraft_audio_io::FileFormat::from_write_extension(format_name).ok_or_else(|| {
|
||||
let supported = soundcraft_audio_io::FileFormat::writable_extensions().join(", ");
|
||||
bad("file.bounce_stems", format!("cannot write `{format_name}` files (supported: {supported})"))
|
||||
})?;
|
||||
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
|
||||
Some("16") => soundcraft_audio_io::BitDepth::Int16,
|
||||
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
|
||||
@@ -142,10 +149,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
crate::io::save_session(&mut tmp, &path)?;
|
||||
Ok(json!({"path": path}))
|
||||
}),
|
||||
cmd!(noundo "file.export_sibelius", "Sibelius...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
|
||||
cmd!(noundo "file.export_sibelius", "MusicXML...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
|
||||
write_score(e, p, "file.export_sibelius", false)
|
||||
}),
|
||||
cmd!(noundo "file.send_to_sibelius", "Sibelius", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| {
|
||||
cmd!(noundo "file.send_to_sibelius", "MusicXML", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| {
|
||||
write_score(e, p, "file.send_to_sibelius", false)
|
||||
}),
|
||||
cmd!(noundo "file.print_score", "Print Score...", ["File"], Some("Cmd+P"), "{path?, tracks?} — engraves the MIDI tracks as a printable SVG page", always, |e, p| {
|
||||
|
||||
@@ -118,6 +118,71 @@ fn import_audio_honours_new_tracks() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_rejects_unsupported_formats_before_writing() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-unsupported-format-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let stems = dir.join("stems");
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "not-a-format", "start": 0, "end": 1}));
|
||||
let created_output = stems.exists();
|
||||
// Own this directory exclusively; remove any baseline output before the assertion can fail.
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let err = result.map(|_| ()).unwrap_err();
|
||||
assert!(err.to_string().contains("cannot write `not-a-format`"), "{err}");
|
||||
assert!(err.to_string().contains("supported: wav"), "{err}");
|
||||
assert!(!created_output, "an unsupported format must not create the output directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_rejects_non_string_formats_before_writing() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-format-type-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
|
||||
let results: Vec<_> = [json!(null), json!(123), json!(false), json!([]), json!({})]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, format)| {
|
||||
let stems = dir.join(index.to_string());
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": format, "start": 0, "end": 1}));
|
||||
(result, stems.exists())
|
||||
})
|
||||
.collect();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
for (result, created_output) in results {
|
||||
let err = result.map(|_| ()).unwrap_err();
|
||||
assert!(err.to_string().contains("`format` must be a string"), "{err}");
|
||||
assert!(!created_output, "a non-string format must not create the output directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_accepts_explicit_wav_format() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-explicit-wav-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let mut e = Engine::default();
|
||||
let wav = tone_wav(&dir, "tone.wav");
|
||||
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
|
||||
let stems = dir.join("stems");
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "wav", "start": 0, "end": 32, "dither": false})).unwrap();
|
||||
let files = result["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
let path = std::path::Path::new(files[0].as_str().unwrap());
|
||||
assert_eq!(path.parent(), Some(stems.as_path()));
|
||||
assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("wav"));
|
||||
let (info, audio) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
|
||||
assert_eq!(info.format, soundcraft_audio_io::FileFormat::Wav);
|
||||
assert_eq!(audio.frames(), 32);
|
||||
assert_eq!(audio.num_channels(), e.session().main_format().channels());
|
||||
assert!(audio.channels.iter().flatten().any(|sample| sample.abs() > 0.01));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_refuses_formats_it_cannot_write() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ macro_rules! toggle {
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
toggle!("options.loop_record", "Loop Record", ["Options"], Some("Alt+L"), loop_record),
|
||||
toggle!("options.quickpunch", "QuickPunch", ["Options"], Some("Cmd+Shift+P"), quickpunch),
|
||||
toggle!("options.quickpunch", "Punch Recording", ["Options"], Some("Cmd+Shift+P"), quickpunch),
|
||||
toggle!("options.pre_post_roll", "Pre/Post-Roll", ["Options"], Some("Cmd+K"), pre_post_roll),
|
||||
toggle!("options.loop_playback", "Loop Playback", ["Options"], Some("Cmd+Shift+L"), loop_playback),
|
||||
toggle!("options.link_timeline_edit", "Link Timeline and Edit Selection", ["Options"], Some("Shift+/"), link_timeline_edit),
|
||||
|
||||
@@ -75,7 +75,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
let buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES)
|
||||
.ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?;
|
||||
.map_err(|r| EngineError::Io(format!("{name}: {}", r.message("too long for a sample (ten minutes of 192 kHz stereo at most)"))))?;
|
||||
let channels = buf.num_channels();
|
||||
let s = e.session_mut();
|
||||
let src = crate::io::add_source(s, name, buf, path, format.format);
|
||||
|
||||
@@ -472,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());
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
flag!("view.transport_counters", "Counters", ["View", "Transport"], "view.transport.counters"),
|
||||
flag!("view.transport_midi_controls", "MIDI Controls", ["View", "Transport"], "view.transport.midi_controls"),
|
||||
flag!("view.transport_synchronization", "Synchronization", ["View", "Transport"], "view.transport.synchronization"),
|
||||
flag!("view.transport_ableton_link", "Ableton Link", ["View", "Transport"], "view.transport.ableton_link"),
|
||||
flag!("view.transport_ableton_link", "Tempo Sync", ["View", "Transport"], "view.transport.ableton_link"),
|
||||
flag!("view.transport_output_meters", "Output Meters", ["View", "Transport"], "view.transport.output_meters"),
|
||||
flag!("view.transport_expanded", "Expanded", ["View", "Transport"], "view.transport.expanded"),
|
||||
cmd!(query "view.flags", "View Flags", [], None, "{prefix?} — lists the view/option flags that are on", always, |e, p| {
|
||||
|
||||
+127
-6
@@ -1,12 +1,15 @@
|
||||
//! Borrowed document equality for the saved-state indicator.
|
||||
|
||||
use soundcraft_model::{EditState, Session};
|
||||
use soundcraft_model::{EditState, Mixer, Session, Track};
|
||||
|
||||
/// Equal apart from the selection, playhead and zoom, without copying document data.
|
||||
/// 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 {
|
||||
@@ -18,7 +21,7 @@ fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl Partia
|
||||
timecode_start,
|
||||
tempo,
|
||||
key_signatures,
|
||||
tracks,
|
||||
tracks: _,
|
||||
sources,
|
||||
videos,
|
||||
busses,
|
||||
@@ -35,11 +38,66 @@ fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl Partia
|
||||
} = s;
|
||||
// Tuples implement PartialEq up to twelve elements. These contain only borrowed fields.
|
||||
(
|
||||
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, tracks, sources, videos, busses),
|
||||
(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,
|
||||
@@ -52,6 +110,9 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
|
||||
grid,
|
||||
nudge,
|
||||
grid_lines,
|
||||
grid_follow_main,
|
||||
nudge_follow_main,
|
||||
grid_clips_markers,
|
||||
main_counter,
|
||||
sub_counter,
|
||||
zoom: _,
|
||||
@@ -130,7 +191,17 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
|
||||
scrolling,
|
||||
solo_mode,
|
||||
),
|
||||
(pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values),
|
||||
(
|
||||
grid_follow_main,
|
||||
nudge_follow_main,
|
||||
grid_clips_markers,
|
||||
pre_fader_metering,
|
||||
delay_compensation,
|
||||
keyboard_focus,
|
||||
&navigation.timeline_selection,
|
||||
flags,
|
||||
values,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,7 +212,7 @@ mod tests {
|
||||
use soundcraft_time::{Range, TimeFormat};
|
||||
use std::sync::Arc;
|
||||
|
||||
// The previous comparison is an oracle for preserving the saved-state policy.
|
||||
/// 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);
|
||||
@@ -151,9 +222,14 @@ mod tests {
|
||||
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");
|
||||
@@ -186,6 +262,9 @@ mod tests {
|
||||
("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() })),
|
||||
@@ -252,6 +331,48 @@ mod tests {
|
||||
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();
|
||||
|
||||
+76
-25
@@ -78,9 +78,9 @@ fn rebase_midi_start(clip: &mut Clip, old_start: Samples, t: TimeCtx<'_>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split every clip on a track's active playlist that spans `at`. The piece before `at` keeps the
|
||||
/// clip's name. The piece after it is a new clip, and takes the next name of the clip's Auto Rename
|
||||
/// scheme when it has one.
|
||||
/// Split every clip on a track's active playlist that spans `at`, except time- or edit-locked ones.
|
||||
/// The piece before `at` keeps the clip's name. The piece after it is a new clip, and takes the next
|
||||
/// name of the clip's Auto Rename scheme when it has one.
|
||||
pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
separate_at_if(s, track, at, |_| true)
|
||||
}
|
||||
@@ -88,11 +88,21 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
/// [`separate_at`], giving the piece after `at` the next Auto Rename name only when `auto_name`
|
||||
/// returns true for it. Any other piece keeps the clip's name, and its scheme does not count up.
|
||||
pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool) -> usize {
|
||||
split_at(s, track, at, auto_name, true)
|
||||
}
|
||||
|
||||
/// [`separate_at`], splitting time- and edit-locked clips too. For the Event time operations, which
|
||||
/// move the whole timeline, locked clips included.
|
||||
pub fn separate_all_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
split_at(s, track, at, |_| true, false)
|
||||
}
|
||||
|
||||
fn split_at(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool, skip_locked: bool) -> usize {
|
||||
let tc_tempo = s.tempo.clone();
|
||||
let sr = s.sample_rate;
|
||||
let t = TimeCtx { tempo: &tc_tempo, sr };
|
||||
let mut ids = Vec::new();
|
||||
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end()).count());
|
||||
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end() && !(skip_locked && locked(c))).count());
|
||||
for _ in 0..n_split {
|
||||
ids.push(s.new_clip_id());
|
||||
}
|
||||
@@ -106,6 +116,7 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
|
||||
for c in pl.clips.drain(..) {
|
||||
if at > c.start
|
||||
&& at < c.end()
|
||||
&& !(skip_locked && locked(&c))
|
||||
&& let Some(id) = ids.pop()
|
||||
&& let Some((l, mut r)) = split_clip(&c, at, id, t)
|
||||
{
|
||||
@@ -125,6 +136,45 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
|
||||
count
|
||||
}
|
||||
|
||||
/// Split every clip on a track's active playlist at each of `points`, in one pass over the
|
||||
/// playlist: [`separate_at_if`] once per point with `|_| false` (no piece takes an Auto Rename
|
||||
/// name here), without rebuilding the playlist per point. Returns the number of splits.
|
||||
/// `points` are sorted and deduplicated first, so the source offsets of stretched audio can differ
|
||||
/// from per-point calls given in another order (each trim rounds its own adjustment).
|
||||
pub fn separate_at_points(s: &mut Session, track: TrackId, points: &[Samples]) -> usize {
|
||||
let mut pts = points.to_vec();
|
||||
pts.sort_unstable();
|
||||
pts.dedup();
|
||||
let tc_tempo = s.tempo.clone();
|
||||
let t = TimeCtx { tempo: &tc_tempo, sr: s.sample_rate };
|
||||
// The points strictly inside a clip.
|
||||
let within = |c: &Clip| {
|
||||
let (lo, hi) = (pts.partition_point(|p| *p <= c.start), pts.partition_point(|p| *p < c.end()));
|
||||
lo..hi.max(lo)
|
||||
};
|
||||
let n_split: usize = s.track(track).map_or(0, |tr| tr.clips().iter().map(|c| within(c).len()).sum());
|
||||
let mut ids: Vec<ClipId> = (0..n_split).map(|_| s.new_clip_id()).collect();
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut out = Vec::with_capacity(pl.clips.len() + n_split);
|
||||
for c in pl.clips.drain(..) {
|
||||
let mut rest = c;
|
||||
for p in &pts[within(&rest)] {
|
||||
let Some(id) = ids.pop() else { break };
|
||||
match split_clip(&rest, *p, id, t) {
|
||||
Some((l, r)) => {
|
||||
out.push(l);
|
||||
rest = r;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
out.push(rest);
|
||||
}
|
||||
pl.clips = out;
|
||||
pl.sort();
|
||||
n_split
|
||||
}
|
||||
|
||||
/// Gives `c` the next name of its Auto Rename scheme, when it has one, and counts the scheme up.
|
||||
fn take_auto_name(schemes: &mut std::collections::BTreeMap<u64, soundcraft_model::AutoName>, c: &mut Clip) {
|
||||
if let Some(scheme) = c.auto_name.and_then(|k| schemes.get_mut(&k)) {
|
||||
@@ -427,6 +477,10 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
// Locked clips keep their extent, like they don't move for nudge.
|
||||
if locked(c) {
|
||||
continue;
|
||||
}
|
||||
if let Some(i) = c.range().intersect(&range)
|
||||
&& i != c.range()
|
||||
{
|
||||
@@ -448,6 +502,9 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
if locked(c) {
|
||||
continue;
|
||||
}
|
||||
if c.range().contains(at) && at > c.start {
|
||||
let old = c.start;
|
||||
c.trim_start_to(at);
|
||||
@@ -458,10 +515,14 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// Trim the end of clips after `at` to `at` (Trim End to Insertion).
|
||||
pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
if locked(c) {
|
||||
continue;
|
||||
}
|
||||
if at > c.start && at < c.end() {
|
||||
c.trim_end_to(at);
|
||||
n += 1;
|
||||
@@ -528,9 +589,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;
|
||||
@@ -546,8 +613,6 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
|
||||
}
|
||||
}
|
||||
let n = moving.len();
|
||||
// Limit the shift once, so the group stops at zero together and keeps its spacing.
|
||||
let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0));
|
||||
let placed = moving.into_iter().map(|(tid, mut c)| {
|
||||
c.start = c.start.saturating_add(delta);
|
||||
(lands(dest, tid), c)
|
||||
@@ -572,20 +637,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
|
||||
@@ -790,12 +842,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
|
||||
|
||||
@@ -330,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]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Display-only terminology. Catalog paths, command ids, processor ids and saved values are stable.
|
||||
//! Never use the returned label for command dispatch or persistence. Labels for features we do not
|
||||
//! have describe a placeholder, which the menu draws disabled ("Not available yet.") until the
|
||||
//! behaviour exists: `catalog::PLACEHOLDERS` lists them.
|
||||
//!
|
||||
//! Names still being settled (RFC-0029) are not mapped here: the incumbent's label stays until the
|
||||
//! RFC decides, so AudioSuite, Elastic Audio, Elastic Properties and TrackPunch pass through.
|
||||
|
||||
pub fn display_label(label: &str) -> &str {
|
||||
match label {
|
||||
"Beat Detective" => "Beat Analysis",
|
||||
"x-form" => "Rendered (High Quality)",
|
||||
"QuickPunch" => "Punch Recording",
|
||||
"Ableton Link" => "Tempo Sync",
|
||||
"Pro Tools Search" => "Search",
|
||||
"Pro Tools Projects" => "Shared Projects",
|
||||
"Melodyne" => "No ARA plug-ins",
|
||||
"HEAT" => "Analog Saturation",
|
||||
"Activate HEAT" => "Activate Analog Saturation",
|
||||
"Sibelius" => "MusicXML",
|
||||
"Sibelius..." => "MusicXML...",
|
||||
_ => label,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn feature_labels_are_descriptive_without_changing_unknown_text() {
|
||||
for (old, new) in [
|
||||
("Beat Detective", "Beat Analysis"),
|
||||
("x-form", "Rendered (High Quality)"),
|
||||
("QuickPunch", "Punch Recording"),
|
||||
("Ableton Link", "Tempo Sync"),
|
||||
("Pro Tools Search", "Search"),
|
||||
("Pro Tools Projects", "Shared Projects"),
|
||||
("Melodyne", "No ARA plug-ins"),
|
||||
("HEAT", "Analog Saturation"),
|
||||
("Activate HEAT", "Activate Analog Saturation"),
|
||||
("Sibelius...", "MusicXML..."),
|
||||
] {
|
||||
assert_eq!(display_label(old), new);
|
||||
}
|
||||
assert_eq!(display_label("My session"), "My session");
|
||||
}
|
||||
|
||||
/// Names deferred to RFC-0029 keep the incumbent's label until the RFC settles them.
|
||||
#[test]
|
||||
fn deferred_names_pass_through() {
|
||||
for label in ["AudioSuite", "AudioSuite Process", "Elastic Audio", "Elastic Properties", "Enable Elastic Audio on New Tracks", "TrackPunch"] {
|
||||
assert_eq!(display_label(label), label);
|
||||
}
|
||||
}
|
||||
}
|
||||
+704
-39
@@ -42,19 +42,185 @@ 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)
|
||||
}
|
||||
|
||||
/// `O_NONBLOCK` of the platforms that have a FIFO to wait on (std has no name for it, and this crate has no unsafe
|
||||
/// code or libc); 0 elsewhere, where a FIFO open still waits for a writer.
|
||||
#[cfg(unix)]
|
||||
const O_NONBLOCK: i32 = if cfg!(all(
|
||||
any(target_os = "linux", target_os = "android"),
|
||||
not(any(
|
||||
target_arch = "mips",
|
||||
target_arch = "mips32r6",
|
||||
target_arch = "mips64",
|
||||
target_arch = "mips64r6",
|
||||
target_arch = "sparc",
|
||||
target_arch = "sparc64"
|
||||
))
|
||||
)) {
|
||||
0x800
|
||||
} else if cfg!(any(
|
||||
target_os = "macos",
|
||||
target_os = "ios",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "dragonfly"
|
||||
)) {
|
||||
0x4
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
/// [`read_file`] for a file that must lie inside `root` (a canonical folder): the file is opened
|
||||
/// once, and the bytes come from that handle only after the handle is shown to be the file the
|
||||
/// path resolves to inside `root`. A file swapped in for the path after a check by name is
|
||||
/// refused instead of read. Where the handle's identity can't be read (non-Unix targets) the
|
||||
/// resolved path is checked against `root` alone.
|
||||
pub(crate) fn read_file_inside(path: &Path, root: &Path) -> Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
crate::file_access::check_read(path).map_err(EngineError::Io)?;
|
||||
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
|
||||
// opened without waiting for a FIFO's writer (the type is checked on the opened handle, below)
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, O_NONBLOCK);
|
||||
let mut file = options.open(path).map_err(err)?;
|
||||
if !file.metadata().map_err(err)?.is_file() {
|
||||
return Err(EngineError::Io(format!("{}: not a regular file", path.display())));
|
||||
}
|
||||
let key = canonical_path(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let (a, b) = (file.metadata().map_err(err)?, std::fs::metadata(&key).map_err(err)?);
|
||||
if (a.dev(), a.ino()) != (b.dev(), b.ino()) {
|
||||
return Err(EngineError::Io(format!("{}: changed while it was opened", path.display())));
|
||||
}
|
||||
}
|
||||
if !key.starts_with(root) {
|
||||
return Err(EngineError::Io(format!("{}: outside the folder it must stay in", path.display())));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes).map_err(err)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// [`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
|
||||
@@ -75,8 +241,12 @@ pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf,
|
||||
craft_docpath::resolve_absolute(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))
|
||||
}
|
||||
|
||||
/// Create `dir` and its parents, inside the automation write root when there is one.
|
||||
/// Create `dir` and its parents, inside the automation write root when there is one. The browser
|
||||
/// has no folders: there each file is delivered by its name ([`write_file`]) and this does nothing.
|
||||
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Ok(());
|
||||
}
|
||||
let dir = dir.as_ref();
|
||||
// The empty folder of a bare file name ("t.scraft") is the working directory, which the root
|
||||
// check would refuse as an empty path.
|
||||
@@ -85,6 +255,17 @@ pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))
|
||||
}
|
||||
|
||||
/// `path` with its links followed, inside the automation read root when there is one. The root is
|
||||
/// checked on the path as named (links followed, a missing tail resolved by its nearest folder)
|
||||
/// before anything is resolved, so a name that leads out of the root is refused the same way
|
||||
/// whether or not it exists there.
|
||||
pub(crate) fn canonical_path(path: impl AsRef<Path>) -> Result<PathBuf> {
|
||||
let path = path.as_ref();
|
||||
let checked = if path.as_os_str().is_empty() { Path::new(".") } else { path };
|
||||
crate::file_access::check_read(checked).map_err(EngineError::Io)?;
|
||||
std::fs::canonicalize(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
|
||||
}
|
||||
|
||||
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
|
||||
pub(crate) fn write_path(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
@@ -178,6 +359,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 +396,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))?;
|
||||
@@ -329,7 +520,7 @@ impl Out {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Ok(Out::Memory(std::io::Cursor::new(Vec::new())));
|
||||
}
|
||||
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, std::fs::File::create(part)?)))
|
||||
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, create_new(part)?)))
|
||||
}
|
||||
|
||||
/// Puts the finished file at `path` and returns its size: the part file is synced and renamed
|
||||
@@ -450,29 +641,68 @@ impl From<StripNames> for soundcraft_model::AutoName {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write each clip as a WAV file named after it into `dir` (in the browser, delivered by name
|
||||
/// through [`write_file`]). Returns how many were written.
|
||||
pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result<usize> {
|
||||
let s = e.session();
|
||||
make_dir(dir)?;
|
||||
let mut n = 0;
|
||||
let mut names = ClipNames::default();
|
||||
for id in ids {
|
||||
let Some((t, c)) = s.find_clip(*id) else { continue };
|
||||
let audio = soundcraft_mix::render_clips(s, t, c.range());
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let stem = sanitize_name(&c.name);
|
||||
let mut path = Path::new(dir).join(format!("{stem}.wav"));
|
||||
let mut k = 2u32;
|
||||
while path.exists() {
|
||||
path = Path::new(dir).join(format!("{stem}-{k}.wav"));
|
||||
k = k.saturating_add(1);
|
||||
if k > 10_000 {
|
||||
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
|
||||
}
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
let path = clip_file(dir, &stem, &mut names, Path::exists)?;
|
||||
write_file(&path.to_string_lossy(), &bytes)?;
|
||||
continue;
|
||||
}
|
||||
write_path(&path, bytes)?;
|
||||
n += 1;
|
||||
// The name is taken by creating the file, so whatever entry already has it (a link
|
||||
// to elsewhere included) is left alone rather than written through. An occupied name is
|
||||
// taken whatever it is (a link whose target is outside the write root too): it is only
|
||||
// looked at, never resolved, so the root check does not stop the search for a free name.
|
||||
// The folder itself was checked above.
|
||||
let (mut file, path) = loop {
|
||||
let path = clip_file(dir, &stem, &mut names, |p| std::fs::symlink_metadata(p).is_ok())?;
|
||||
crate::file_access::check_write(&path).map_err(EngineError::Io)?;
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(f) => break (f, path),
|
||||
// taken since it was looked at: `names` has it now, so the next name is tried
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
|
||||
}
|
||||
};
|
||||
std::io::Write::write_all(&mut file, &bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
|
||||
}
|
||||
Ok(n)
|
||||
Ok(names.taken.len())
|
||||
}
|
||||
|
||||
/// The files an export has named so far, and for each name the suffix to try next, so a run of
|
||||
/// same-named clips doesn't count up from 2 again for every clip.
|
||||
#[derive(Default)]
|
||||
struct ClipNames {
|
||||
taken: std::collections::HashSet<PathBuf>,
|
||||
next: std::collections::HashMap<String, u32>,
|
||||
}
|
||||
|
||||
/// The first of `<stem>.wav`, `<stem>-2.wav`, … in `dir` that is neither `on_disk` nor already
|
||||
/// named by this export, recorded in `names`: the browser has no disk to look at, so same-named
|
||||
/// clips are kept apart by `names`.
|
||||
fn clip_file(dir: &str, stem: &str, names: &mut ClipNames, on_disk: impl Fn(&Path) -> bool) -> Result<PathBuf> {
|
||||
let file = |k: u32| Path::new(dir).join(if k < 2 { format!("{stem}.wav") } else { format!("{stem}-{k}.wav") });
|
||||
let mut k = names.next.get(stem).copied().unwrap_or(1);
|
||||
let mut path = file(k);
|
||||
while on_disk(&path) || names.taken.contains(&path) {
|
||||
k = k.saturating_add(1);
|
||||
if k > 10_000 {
|
||||
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
|
||||
}
|
||||
path = file(k);
|
||||
}
|
||||
names.next.insert(stem.to_owned(), k.saturating_add(1));
|
||||
names.taken.insert(path.clone());
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Write `session` to `path`; returns the session with updated media paths, the number of audio
|
||||
@@ -487,7 +717,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 +725,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()
|
||||
@@ -506,13 +738,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
{
|
||||
src.path = f.join(&src.path).to_string_lossy().into_owned();
|
||||
}
|
||||
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
|
||||
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && file_found(dir.join(&src.path));
|
||||
let shared = shared_paths.get(i).copied().unwrap_or(false);
|
||||
if exists_rel && !src.unsaved && !shared {
|
||||
continue;
|
||||
}
|
||||
let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string);
|
||||
if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) {
|
||||
if let Some(f) = in_folder.filter(|_| !src.unsaved && file_found(&src.path)) {
|
||||
src.path = format!("Audio Files/{f}");
|
||||
continue;
|
||||
}
|
||||
@@ -523,35 +755,68 @@ 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.
|
||||
// (With only an automation write root, `file_found` cannot read an existing file, so each save
|
||||
// writes a new numbered WAV: reuse never reads a file the roots do not allow, which keeps a save
|
||||
// from revealing whether an unreadable file matches the audio.)
|
||||
stem = format!("{stem}_{}", src.id.0);
|
||||
if audio_dir.join(format!("{stem}.wav")).exists() {
|
||||
if file_found(audio_dir.join(format!("{stem}.wav"))) {
|
||||
src.path = format!("Audio Files/{stem}.wav");
|
||||
src.unsaved = false;
|
||||
files.insert(Arc::as_ptr(audio), src.path.clone());
|
||||
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");
|
||||
@@ -588,11 +853,22 @@ fn sync_dir(dir: &Path) {
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
let mut f = create_new(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// Creates `path` as a new file. A leftover of that name (a part file from an interrupted write)
|
||||
/// is removed first, but an existing entry is never opened: a link planted at a predictable name
|
||||
/// in the destination folder can't redirect the write elsewhere.
|
||||
fn create_new(path: &Path) -> std::io::Result<std::fs::File> {
|
||||
match std::fs::remove_file(path) {
|
||||
Err(err) if err.kind() != std::io::ErrorKind::NotFound => return Err(err),
|
||||
_ => {}
|
||||
}
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(path)
|
||||
}
|
||||
|
||||
/// Copies `from` to `to`, which must not exist yet; an existing entry, even a link, is never followed.
|
||||
fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
let mut src = std::fs::File::open(from)?;
|
||||
@@ -672,7 +948,8 @@ fn same_path(a: &Path, b: &Path) -> bool {
|
||||
|
||||
/// Store `bytes` as the first of `<stem>.wav`, `<stem>_01.wav`, … in `dir` that is free or already
|
||||
/// holds exactly these bytes, so saving never replaces audio; returns the name and whether it was
|
||||
/// written.
|
||||
/// written. Where the automation roots allow writing in `dir` but not reading (only a write root),
|
||||
/// an existing file is never compared, so each save writes a new numbered WAV.
|
||||
fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
|
||||
use std::io::Write;
|
||||
for k in 0..10_000u32 {
|
||||
@@ -687,7 +964,12 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
|
||||
return Ok((name, true));
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
if std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64) && std::fs::read(&path).is_ok_and(|b| b == bytes) {
|
||||
// Reused only when it may be read: whether it equals the audio must not be
|
||||
// learnt about a file the roots allow writing beside but not reading.
|
||||
if crate::file_access::check_read(&path).is_ok()
|
||||
&& std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64)
|
||||
&& std::fs::read(&path).is_ok_and(|b| b == bytes)
|
||||
{
|
||||
return Ok((name, false));
|
||||
}
|
||||
}
|
||||
@@ -723,7 +1005,99 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
return deliver(name, bytes).map_err(EngineError::Io);
|
||||
}
|
||||
crate::file_access::check_write(path).map_err(EngineError::Io)?;
|
||||
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
|
||||
// Follow existing symlinks, and refuse dangling links even without automation roots.
|
||||
let named = Path::new(path);
|
||||
let path = match std::fs::symlink_metadata(named) {
|
||||
Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(named).map_err(|err| EngineError::Io(format!("{path}: {err}")))?,
|
||||
Ok(_) => named.to_path_buf(),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => named.to_path_buf(),
|
||||
Err(err) => return Err(EngineError::Io(format!("{path}: {err}"))),
|
||||
};
|
||||
crate::file_access::check_write(&path).map_err(EngineError::Io)?;
|
||||
let metadata = match std::fs::metadata(&path) {
|
||||
Ok(m) if m.is_file() => Some(m),
|
||||
Ok(_) => return Err(EngineError::Io(format!("{}: not a regular file", path.display()))),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
|
||||
};
|
||||
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
|
||||
if metadata.as_ref().is_some_and(|m| m.permissions().readonly()) {
|
||||
return Err(err(std::io::ErrorKind::PermissionDenied.into()));
|
||||
}
|
||||
write_export_checked(&path, bytes).map_err(err)
|
||||
}
|
||||
|
||||
/// Keep the original file and its security metadata. Refuse hard links before truncation: a
|
||||
/// link inside an automation root can name a file outside it (#1359).
|
||||
#[cfg(unix)]
|
||||
fn write_export_checked(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(false).mode(0o600).open(path)?;
|
||||
let opened = file.metadata()?;
|
||||
if !opened.is_file() {
|
||||
return Err(std::io::Error::other("not a regular file"));
|
||||
}
|
||||
if opened.nlink() > 1 {
|
||||
return Err(hard_link_export_error());
|
||||
}
|
||||
file.set_len(0)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
fn hard_link_export_error() -> std::io::Error {
|
||||
std::io::Error::other("refusing to overwrite a hard-linked file; choose a new export path")
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn write_export_checked(_path: &Path, _bytes: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
|
||||
/// Windows' stable std metadata API does not expose the link count. Use a safe kernel wrapper
|
||||
/// and the same handle for the check and write; no replacement means the destination DACL stays.
|
||||
#[cfg(windows)]
|
||||
fn write_export_checked(path: &Path, mut bytes: &[u8]) -> std::io::Result<()> {
|
||||
use winsafe::{HFILE, co};
|
||||
// Let std retain its Windows filename normalization and create missing destinations without
|
||||
// truncation. Canonicalization then supplies a verbatim path, including long and UNC paths,
|
||||
// to the raw Win32 wrapper. The handle below is checked before any existing bytes change.
|
||||
let _opened = std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
|
||||
let absolute = std::fs::canonicalize(path)?;
|
||||
let path = absolute.to_str().filter(|p| !p.contains('\0')).ok_or(std::io::ErrorKind::InvalidInput)?;
|
||||
let io_error = |err: winsafe::co::ERROR| std::io::Error::other(err.to_string());
|
||||
let (file, _) = HFILE::CreateFile(
|
||||
path,
|
||||
co::GENERIC::WRITE,
|
||||
Some(co::FILE_SHARE::READ | co::FILE_SHARE::WRITE | co::FILE_SHARE::DELETE),
|
||||
None,
|
||||
co::DISPOSITION::OPEN_EXISTING,
|
||||
co::FILE_ATTRIBUTE::NORMAL,
|
||||
Some(co::FILE_FLAG::WRITE_THROUGH),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(io_error)?;
|
||||
if file.GetFileType().map_err(io_error)? != co::FILE_TYPE::DISK {
|
||||
return Err(std::io::Error::other("not a regular file"));
|
||||
}
|
||||
match file.GetFileInformationByHandle().map_err(io_error)?.nNumberOfLinks {
|
||||
1 => {}
|
||||
0 => return Err(std::io::ErrorKind::Unsupported.into()),
|
||||
_ => return Err(hard_link_export_error()),
|
||||
}
|
||||
file.SetEndOfFile().map_err(io_error)?;
|
||||
while !bytes.is_empty() {
|
||||
// Win32 takes a DWORD length. Small bounded writes also handle short writes correctly.
|
||||
let count = file.WriteFile(&bytes[..bytes.len().min(64 * 1024)]).map_err(io_error)? as usize;
|
||||
if count == 0 {
|
||||
return Err(std::io::ErrorKind::WriteZero.into());
|
||||
}
|
||||
bytes = &bytes[count..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
|
||||
@@ -734,6 +1108,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)?;
|
||||
@@ -746,10 +1126,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 {
|
||||
@@ -785,6 +1168,89 @@ pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exclusive_tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-io-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_planted_link_at_the_part_name_is_not_written_through() {
|
||||
let dir = scratch("part-link");
|
||||
let (target, part) = (dir.join("elsewhere"), dir.join("session.scraft.tmp"));
|
||||
std::os::unix::fs::symlink(&target, &part).unwrap();
|
||||
write_synced(&part, b"new").unwrap();
|
||||
assert!(!target.exists(), "the link's target was created");
|
||||
assert_eq!(std::fs::read(&part).unwrap(), b"new", "the link itself was replaced by the file");
|
||||
// A leftover regular part file is still replaced.
|
||||
write_synced(&part, b"newer").unwrap();
|
||||
assert_eq!(std::fs::read(&part).unwrap(), b"newer");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_file_read_within_a_root_must_resolve_inside_it() {
|
||||
let dir = scratch("within");
|
||||
let (inside, outside) = (dir.join("group"), dir.join("elsewhere"));
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
std::fs::write(inside.join("a.wav"), b"a").unwrap();
|
||||
std::fs::write(outside.join("b.wav"), b"b").unwrap();
|
||||
std::os::unix::fs::symlink(outside.join("b.wav"), inside.join("link.wav")).unwrap();
|
||||
let root = canonical_path(&inside).unwrap();
|
||||
assert_eq!(read_file_inside(&inside.join("a.wav"), &root).unwrap(), b"a");
|
||||
assert!(read_file_inside(&inside.join("link.wav"), &root).is_err());
|
||||
// a FIFO with no writer is refused, not waited on (Linux: the coreutils tool makes it)
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::process::Command::new("mkfifo").arg(inside.join("pipe.wav")).status().is_ok_and(|s| s.success()) {
|
||||
let err = read_file_inside(&inside.join("pipe.wav"), &root).unwrap_err().to_string();
|
||||
assert!(err.contains("not a regular file"), "{err}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn export_skips_a_name_held_by_a_dangling_link() {
|
||||
let dir = scratch("export-link");
|
||||
let e = crate::demo::demo_engine();
|
||||
let (t, c) = e.session().tracks.iter().find_map(|t| t.clips().iter().find(|c| c.is_audio()).map(|c| (t.id, c.clone()))).unwrap();
|
||||
let _ = t;
|
||||
let target = dir.join("elsewhere.wav");
|
||||
std::os::unix::fs::symlink(&target, dir.join(format!("{}.wav", sanitize_name(&c.name)))).unwrap();
|
||||
assert_eq!(export_clips(&e, &[c.id], &dir.to_string_lossy()).unwrap(), 1);
|
||||
assert!(!target.exists(), "the export followed the link");
|
||||
assert!(dir.join(format!("{}-2.wav", sanitize_name(&c.name))).is_file(), "the next free name was used");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// With a write root, a link out of it that holds the name is still just an occupied name.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn export_skips_a_link_out_of_the_write_root_too() {
|
||||
let dir = scratch("export-root-link");
|
||||
let e = crate::demo::demo_engine();
|
||||
let c = e.session().tracks.iter().find_map(|t| t.clips().iter().find(|c| c.is_audio()).cloned()).unwrap();
|
||||
let elsewhere = scratch("export-root-elsewhere");
|
||||
let target = elsewhere.join("target.wav");
|
||||
std::os::unix::fs::symlink(&target, dir.join(format!("{}.wav", sanitize_name(&c.name)))).unwrap();
|
||||
let roots = crate::file_access::AutomationRoots::new(&[], &[&dir]).unwrap().unwrap();
|
||||
let n = crate::file_access::confine(Some(&roots), || export_clips(&e, &[c.id], &dir.to_string_lossy())).unwrap();
|
||||
assert_eq!(n, 1);
|
||||
assert!(!target.exists(), "the export followed the link out of the root");
|
||||
assert!(dir.join(format!("{}-2.wav", sanitize_name(&c.name))).is_file(), "the next free name was used");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&elsewhere);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stem_tests {
|
||||
#[test]
|
||||
@@ -842,3 +1308,202 @@ mod stem_tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod export_replacement_tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-export-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
#[test]
|
||||
fn export_refuses_hard_links_before_truncating_and_refuses_nonfiles() {
|
||||
let dir = scratch("hard-links");
|
||||
let original = dir.join("original.mid");
|
||||
let write_root = dir.join("write");
|
||||
std::fs::create_dir(&write_root).unwrap();
|
||||
let destination = write_root.join("export.mid");
|
||||
std::fs::write(&original, b"old").unwrap();
|
||||
std::fs::hard_link(&original, &destination).unwrap();
|
||||
for confined in [false, true] {
|
||||
let roots = if confined { crate::file_access::AutomationRoots::new(&[], &[&write_root]).unwrap() } else { None };
|
||||
let result = crate::file_access::confine(roots.as_ref(), || write_file(destination.to_str().unwrap(), b"new"));
|
||||
assert!(result.unwrap_err().to_string().contains("hard-linked"));
|
||||
}
|
||||
assert_eq!(std::fs::read(&original).unwrap(), b"old");
|
||||
assert_eq!(std::fs::read(&destination).unwrap(), b"old");
|
||||
let folder = dir.join("folder");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
assert!(write_file(folder.to_str().unwrap(), b"new").is_err());
|
||||
assert!(folder.is_dir());
|
||||
assert!(!std::fs::read_dir(&dir).unwrap().any(|e| e.unwrap().file_name().to_string_lossy().starts_with(".soundcraft-")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn export_supports_paths_longer_than_max_path() {
|
||||
let root = scratch("long-path");
|
||||
let mut dir = root.clone();
|
||||
while dir.as_os_str().len() < 280 {
|
||||
dir.push("long-folder-component");
|
||||
}
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("export.mid");
|
||||
write_file(path.to_str().unwrap(), b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
write_file(path.to_str().unwrap(), b"replacement").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn export_writes_all_chunks_and_truncates_an_existing_file() {
|
||||
let dir = scratch("chunks");
|
||||
let path = dir.join("export.mid");
|
||||
let bytes = vec![0x5a; 128 * 1024 + 17];
|
||||
write_file(path.to_str().unwrap(), &bytes).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
write_file(path.to_str().unwrap(), b"").unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn export_refuses_readonly_files_without_changing_their_bytes() {
|
||||
let dir = scratch("readonly");
|
||||
let path = dir.join("readonly.mid");
|
||||
std::fs::write(&path, b"old contents").unwrap();
|
||||
let original = std::fs::metadata(&path).unwrap().permissions();
|
||||
let mut readonly = original.clone();
|
||||
readonly.set_readonly(true);
|
||||
std::fs::set_permissions(&path, readonly).unwrap();
|
||||
let result = write_file(path.to_str().unwrap(), b"new");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
std::fs::set_permissions(&path, original).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(bytes, b"old contents");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn export_preserves_the_existing_file_identity_and_security_metadata() {
|
||||
let dir = scratch("identity");
|
||||
let path = dir.join("existing.mid");
|
||||
std::fs::write(&path, b"long old contents").unwrap();
|
||||
#[cfg(unix)]
|
||||
let identity = |p: &Path| {
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let m = std::fs::metadata(p).unwrap();
|
||||
(m.dev(), m.ino(), m.uid(), m.gid())
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let identity = |p: &Path| {
|
||||
use winsafe::{HFILE, co};
|
||||
let (file, _) = HFILE::CreateFile(
|
||||
p.to_str().unwrap(),
|
||||
co::GENERIC::READ,
|
||||
None,
|
||||
None,
|
||||
co::DISPOSITION::OPEN_EXISTING,
|
||||
co::FILE_ATTRIBUTE::NORMAL,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let i = file.GetFileInformationByHandle().unwrap();
|
||||
(i.dwVolumeSerialNumber, i.nFileIndex())
|
||||
};
|
||||
let before = identity(&path);
|
||||
write_file(path.to_str().unwrap(), b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
// No inode/file-record replacement: owner, group and access-control lists stay attached
|
||||
// to the same file rather than being inherited anew from the parent directory.
|
||||
assert_eq!(identity(&path), before);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn export_replacement_preserves_existing_permissions_and_new_files_are_private() {
|
||||
let dir = scratch("permissions");
|
||||
for mode in [0o600, 0o640] {
|
||||
let path = dir.join(format!("{mode:o}.mid"));
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||
write_file(path.to_str().unwrap(), b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, mode);
|
||||
}
|
||||
let path = dir.join("new.mid");
|
||||
write_file(path.to_str().unwrap(), b"new").unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o077, 0);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn export_replacement_preserves_a_symlink_and_refuses_a_dangling_one() {
|
||||
let dir = scratch("symlinks");
|
||||
let target = dir.join("target.mid");
|
||||
let link = dir.join("link.mid");
|
||||
std::fs::write(&target, b"old").unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
symlink("target.mid", &link).unwrap();
|
||||
for confined in [false, true] {
|
||||
let roots = if confined { crate::file_access::AutomationRoots::new(&[], &[&dir]).unwrap() } else { None };
|
||||
crate::file_access::confine(roots.as_ref(), || write_file(link.to_str().unwrap(), b"new")).unwrap();
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"new");
|
||||
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
let missing = dir.join("missing.mid");
|
||||
let dangling = dir.join("dangling.mid");
|
||||
symlink(&missing, &dangling).unwrap();
|
||||
assert!(write_file(dangling.to_str().unwrap(), b"new").is_err());
|
||||
assert!(std::fs::symlink_metadata(&dangling).unwrap().file_type().is_symlink());
|
||||
assert!(!missing.exists());
|
||||
assert!(!std::fs::read_dir(&dir).unwrap().any(|e| e.unwrap().file_name().to_string_lossy().starts_with(".soundcraft-")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clip_tests {
|
||||
/// The browser has no disk to find earlier files on: same-named clips get their own names
|
||||
/// from the export itself, as they do from the folder natively (#1409).
|
||||
#[test]
|
||||
fn same_named_clips_get_their_own_names_with_nothing_on_disk() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-clip-names-{}", std::process::id()));
|
||||
let mut names = super::ClipNames::default();
|
||||
for want in ["Take.wav", "Take-2.wav", "Take-3.wav"] {
|
||||
let path = super::clip_file(&dir.to_string_lossy(), "Take", &mut names, std::path::Path::exists).unwrap();
|
||||
assert_eq!(path, dir.join(want));
|
||||
}
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
|
||||
/// A file already in the folder is still skipped, and a run of same-named clips picks up where
|
||||
/// the last one left off instead of looking at every earlier name again for each clip.
|
||||
#[test]
|
||||
fn same_named_clips_skip_files_on_disk_and_do_not_count_up_from_the_start_each_time() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-clip-disk-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("Take-2.wav"), b"").unwrap();
|
||||
let looked = std::cell::Cell::new(0usize);
|
||||
let on_disk = |p: &std::path::Path| {
|
||||
looked.set(looked.get() + 1);
|
||||
p.exists()
|
||||
};
|
||||
let mut names = super::ClipNames::default();
|
||||
for want in ["Take.wav", "Take-3.wav", "Take-4.wav"] {
|
||||
let path = super::clip_file(&dir.to_string_lossy(), "Take", &mut names, on_disk).unwrap();
|
||||
assert_eq!(path, dir.join(want));
|
||||
}
|
||||
let before = looked.get();
|
||||
for _ in 0..1_000 {
|
||||
super::clip_file(&dir.to_string_lossy(), "Take", &mut names, on_disk).unwrap();
|
||||
}
|
||||
assert_eq!(looked.get() - before, 1_000);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,18 +7,48 @@ use super::{
|
||||
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
|
||||
pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
|
||||
|
||||
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples: a file with a
|
||||
/// tiny sample rate grows by orders of magnitude.
|
||||
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option<AudioBuffer> {
|
||||
/// The most a file's audio may grow when converted to the session rate. Sessions run at 8 kHz to
|
||||
/// 768 kHz (96×); past this a file is lying about its rate, and converting a few kilobytes would
|
||||
/// otherwise allocate and filter hundreds of megabytes.
|
||||
const MAX_RATE_EXPANSION: u32 = 128;
|
||||
|
||||
/// Why [`at_rate`] refused a conversion.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum RateError {
|
||||
/// The converted audio would hold more samples than the limit.
|
||||
TooLarge,
|
||||
/// The file's rate is so far below the session's that converting would grow it past [`MAX_RATE_EXPANSION`].
|
||||
Ratio { from: u32, to: u32 },
|
||||
}
|
||||
|
||||
impl RateError {
|
||||
/// The refusal as a sentence: `size` says what a size refusal means to the caller.
|
||||
pub(crate) fn message(&self, size: &str) -> String {
|
||||
match self {
|
||||
RateError::TooLarge => size.to_string(),
|
||||
RateError::Ratio { from, to } => {
|
||||
format!("its {from} Hz sample rate is too low to convert to the session's {to} Hz (at most {MAX_RATE_EXPANSION}-fold)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples, or grow more
|
||||
/// than [`MAX_RATE_EXPANSION`]-fold: a file with a tiny sample rate grows by orders of magnitude.
|
||||
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> std::result::Result<AudioBuffer, RateError> {
|
||||
let from = buf.sample_rate.max(1);
|
||||
// widened integers: dividing first would truncate 128.86-fold down to 128
|
||||
if u64::from(rate) > u64::from(from) * u64::from(MAX_RATE_EXPANSION) {
|
||||
return Err(RateError::Ratio { from, to: rate });
|
||||
}
|
||||
if buf.frames() as f64 * buf.num_channels() as f64 * f64::from(rate) / f64::from(from) > limit as f64 {
|
||||
return None;
|
||||
return Err(RateError::TooLarge);
|
||||
}
|
||||
if from != rate {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate);
|
||||
buf.sample_rate = rate;
|
||||
}
|
||||
Some(buf)
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// A relative `Audio Files/` path no other source in `s` already uses.
|
||||
@@ -115,7 +145,7 @@ pub fn decode_import(path: &str, rate: u32) -> Result<DecodedImport> {
|
||||
fn decode_import_bytes(name: &str, bytes: &[u8], rate: u32) -> Result<(soundcraft_audio_io::AudioInfo, Arc<SourceAudio>)> {
|
||||
let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| EngineError::Io(format!("{name}: {}", e.message("file is too large to import"))))?;
|
||||
if buf.frames() == 0 {
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
@@ -215,8 +245,22 @@ mod rate_tests {
|
||||
#[test]
|
||||
fn a_tiny_sample_rate_is_refused_before_it_is_converted() {
|
||||
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
|
||||
assert!(at_rate(buf(1), 48_000, 1_000_000).is_none());
|
||||
assert!(at_rate(buf(1), 48_000, 1_000_000).is_err());
|
||||
let ok = at_rate(buf(8_000), 48_000, 1_000_000).unwrap();
|
||||
assert_eq!((ok.sample_rate, ok.frames()), (48_000, 600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extreme_rate_expansion_is_refused_whatever_the_sample_budget() {
|
||||
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
|
||||
// 100 frames at 300 Hz would fit the budget as 128 000 samples, but grow 640-fold.
|
||||
assert_eq!(at_rate(buf(300), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 300, to: 192_000 });
|
||||
assert!(at_rate(buf(1_500), 192_000, usize::MAX).is_ok(), "128-fold is the supported extreme");
|
||||
assert!(at_rate(buf(8_000), 768_000, usize::MAX).is_ok(), "the slowest file at the fastest session");
|
||||
// 128.86-fold: integer division would truncate it to 128 and let it through
|
||||
assert_eq!(at_rate(buf(1_490), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 1_490, to: 192_000 });
|
||||
// a short file that is only over the sample budget is a size refusal, not a rate one
|
||||
assert_eq!(at_rate(buf(48_000), 48_000, 10).unwrap_err(), RateError::TooLarge);
|
||||
assert!(RateError::Ratio { from: 300, to: 192_000 }.message("big").contains("300 Hz"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -72,7 +72,7 @@ pub(crate) fn open_session_linked(e: &mut Engine, path: &Path, links: &Path) ->
|
||||
/// Decode one source's audio file at the session rate.
|
||||
pub(super) fn decode_source(bytes: &[u8], ext: Option<&str>, rate: u32) -> std::result::Result<Arc<SourceAudio>, String> {
|
||||
let (_, buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or("too large to load")?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| e.message("too large to load"))?;
|
||||
Ok(Arc::new(SourceAudio::new(buf)))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -117,9 +117,7 @@ pub fn strip_silence(
|
||||
}
|
||||
cut_points.push(r.end);
|
||||
let old: std::collections::HashSet<_> = s.track(t).map(|x| x.clips().iter().map(|c| c.id).collect()).unwrap_or_default();
|
||||
for p in &cut_points {
|
||||
crate::edit::separate_at_if(s, t, *p, |_| false);
|
||||
}
|
||||
crate::edit::separate_at_points(s, t, &cut_points);
|
||||
let kept = strip_pieces(s, t, r, &keep, mode);
|
||||
crate::edit::auto_name_new_clips(s, t, &old, |c| !(named && c.is_audio() && c.start >= r.start && c.end() <= r.end));
|
||||
Ok(kept)
|
||||
@@ -144,10 +142,24 @@ fn strip_pieces(s: &mut Session, t: TrackId, r: Range, keep: &[(Samples, Samples
|
||||
let audio: Vec<Range> = keep.iter().map(|(a, b)| Range::new(*a, *b)).collect();
|
||||
let remove = if mode == StripMode::Extract { &audio } else { &gaps };
|
||||
let inside = |c: &Clip, g: &Range| c.start >= g.start && c.end() <= g.end;
|
||||
// The range of `rs` holding `c`: by binary search when `rs` is ascending and disjoint (the
|
||||
// usual case, checked once by the caller), by scan otherwise.
|
||||
let holder = |rs: &[Range], sorted: bool, c: &Clip| {
|
||||
if sorted {
|
||||
let i = rs.partition_point(|g| g.end < c.end());
|
||||
rs.get(i).filter(|g| inside(c, g)).map(|_| i)
|
||||
} else {
|
||||
rs.iter().position(|g| inside(c, g))
|
||||
}
|
||||
};
|
||||
let ordered = |rs: &[Range]| rs.windows(2).all(|w| w[0].end <= w[1].start);
|
||||
let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return if mode == StripMode::Extract { 0 } else { keep.len() } };
|
||||
pl.clips.retain(|c| !remove.iter().any(|g| inside(c, g)));
|
||||
let sorted = ordered(remove);
|
||||
pl.clips.retain(|c| holder(remove, sorted, c).is_none());
|
||||
if mode == StripMode::Extract {
|
||||
return gaps.iter().filter(|g| pl.clips.iter().any(|c| inside(c, g))).count();
|
||||
let sorted = ordered(&gaps);
|
||||
let held: std::collections::HashSet<usize> = pl.clips.iter().filter_map(|c| holder(&gaps, sorted, c)).collect();
|
||||
return held.len();
|
||||
}
|
||||
keep.len()
|
||||
}
|
||||
@@ -294,6 +306,57 @@ mod strip_tests {
|
||||
(r, e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutting_at_many_points_in_one_pass_equals_cutting_at_each() {
|
||||
let spans = |e: &Engine, t: TrackId| {
|
||||
let mut v: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
};
|
||||
let points = [30_000, 10, 5_000, 30_000, 120_000, 0, 20_000, 1_000_000];
|
||||
let (mut one, t) = tones(&[0, 20_000, 100_000], 40_000);
|
||||
let (mut each, _) = tones(&[0, 20_000, 100_000], 40_000);
|
||||
crate::edit::separate_at_points(one.session_mut(), t, &points);
|
||||
for p in points {
|
||||
crate::edit::separate_at_if(each.session_mut(), t, p, |_| false);
|
||||
}
|
||||
assert_eq!(spans(&one, t), spans(&each, t));
|
||||
assert!(spans(&one, t).len() > 3);
|
||||
let ids: std::collections::HashSet<_> = one.session().track(t).unwrap().clips().iter().map(|c| c.id).collect();
|
||||
assert_eq!(ids.len(), spans(&one, t).len(), "new pieces have distinct ids");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutting_stretched_audio_at_ascending_points_matches_cutting_at_each() {
|
||||
let pieces = |e: &Engine, t: TrackId| {
|
||||
let mut v: Vec<_> = e
|
||||
.session()
|
||||
.track(t)
|
||||
.unwrap()
|
||||
.clips()
|
||||
.iter()
|
||||
.map(|c| (c.start, c.end(), if let soundcraft_model::ClipContent::Audio { offset, .. } = c.content { offset } else { -1 }))
|
||||
.collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
};
|
||||
let stretched = || {
|
||||
let (mut e, t) = tones(&[0], 40_000);
|
||||
for c in &mut e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap().clips {
|
||||
c.stretch = 2.0;
|
||||
}
|
||||
(e, t)
|
||||
};
|
||||
let points = [1, 2, 3_001, 12_345];
|
||||
let ((mut one, t), (mut each, _)) = (stretched(), stretched());
|
||||
crate::edit::separate_at_points(one.session_mut(), t, &points);
|
||||
for p in points {
|
||||
crate::edit::separate_at_if(each.session_mut(), t, p, |_| false);
|
||||
}
|
||||
assert_eq!(pieces(&one, t), pieces(&each, t));
|
||||
assert_eq!(pieces(&one, t).len(), points.len() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_keeps_the_audio_and_removes_the_silence() {
|
||||
let (r, clips) = strip_mode("strip");
|
||||
|
||||
+108
-1
@@ -13,6 +13,7 @@ pub mod cmd;
|
||||
pub mod demo;
|
||||
mod dirty;
|
||||
pub mod edit;
|
||||
pub mod feature_names;
|
||||
pub mod file_access;
|
||||
pub mod inspect;
|
||||
pub mod io;
|
||||
@@ -119,6 +120,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`.
|
||||
@@ -128,7 +131,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() }
|
||||
}
|
||||
|
||||
@@ -279,6 +282,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;
|
||||
@@ -334,20 +344,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;
|
||||
@@ -368,6 +392,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) {
|
||||
@@ -684,6 +727,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();
|
||||
@@ -703,6 +799,17 @@ mod dirty_tests {
|
||||
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();
|
||||
|
||||
@@ -146,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;
|
||||
}
|
||||
@@ -410,7 +418,9 @@ fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<Str
|
||||
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;
|
||||
@@ -644,6 +654,58 @@ mod tests {
|
||||
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.
|
||||
@@ -722,6 +784,14 @@ mod tests {
|
||||
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:
|
||||
|
||||
@@ -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)]);
|
||||
@@ -456,3 +579,48 @@ fn moving_a_group_left_past_zero_keeps_its_spacing() {
|
||||
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)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_and_range_edits_leave_locked_clips_whole() {
|
||||
let edits = [
|
||||
("edit.separate", json!({"at": 24_000})),
|
||||
("edit.separate", json!({"start": 12_000, "end": 36_000})),
|
||||
("edit.separate_on_grid", json!({"start": 12_000, "end": 36_000})),
|
||||
("edit.clear", json!({"start": 12_000, "end": 36_000})),
|
||||
("edit.insert_silence", json!({"start": 24_000, "end": 25_000})),
|
||||
];
|
||||
for lock in [None, Some("clip.edit_lock"), Some("clip.time_lock")] {
|
||||
for (cmd, p) in &edits {
|
||||
let (mut e, t, ids) = long("slip", &[(0, 48_000, 0)]);
|
||||
if let Some(lock) = lock {
|
||||
run(&mut e, lock, json!({"clips": [ids[0].0]}));
|
||||
}
|
||||
let mut p = p.clone();
|
||||
p["tracks"] = json!([t.0]);
|
||||
run(&mut e, cmd, p);
|
||||
if lock.is_some() {
|
||||
assert_eq!(clips(&e, t), vec![(0, 48_000)], "{lock:?} {cmd}");
|
||||
} else {
|
||||
assert_ne!(clips(&e, t), vec![(0, 48_000)], "unlocked control {cmd}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Event time edits move the entire timeline, including clips protected from ordinary edits.
|
||||
#[test]
|
||||
fn event_time_edits_split_and_move_locked_clips() {
|
||||
for lock in ["clip.edit_lock", "clip.time_lock"] {
|
||||
for (command, expected) in [
|
||||
("event.insert_time", vec![(0, 12_000, 0), (36_000, 36_000, 12_000)]),
|
||||
("event.cut_time", vec![(0, 12_000, 0), (12_000, 12_000, 36_000)]),
|
||||
] {
|
||||
let (mut e, t, ids) = long("slip", &[(0, 48_000, 0)]);
|
||||
run(&mut e, lock, json!({"clips": [ids[0].0]}));
|
||||
run(&mut e, command, json!({"start": 12_000, "end": 36_000}));
|
||||
let actual: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect();
|
||||
assert_eq!(actual, expected, "{lock} {command}");
|
||||
assert!(e.session().track(t).unwrap().clips().iter().all(soundcraft_engine::edit::locked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -18,3 +18,68 @@ fn trim_to_selection_keeps_clips_sorted() {
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(long, 0, 30), (short, 5, 5)]);
|
||||
}
|
||||
|
||||
fn three_clip_session() -> (Session, soundcraft_model::TrackId, [soundcraft_model::ClipId; 3]) {
|
||||
let mut session = Session::default();
|
||||
let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let ids = [session.new_clip_id(), session.new_clip_id(), session.new_clip_id()];
|
||||
session.track_mut(track).unwrap().playlist_mut().unwrap().clips = vec![
|
||||
Clip::audio(ids[0], "free", SourceId(1), 0, 0, 100),
|
||||
Clip::audio(ids[1], "edit-locked", SourceId(1), 0, 100, 100),
|
||||
Clip::audio(ids[2], "time-locked", SourceId(1), 0, 200, 100),
|
||||
];
|
||||
let pl = session.track_mut(track).unwrap().playlist_mut().unwrap();
|
||||
pl.clips[1].edit_locked = true;
|
||||
pl.clips[2].time_locked = true;
|
||||
(session, track, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_to_selection_leaves_locked_clips_whole() {
|
||||
let (session, track, ids) = three_clip_session();
|
||||
let mut engine = Engine::new(session);
|
||||
let result = engine.execute("edit.trim_to_selection", &json!({"tracks": [track.0], "start": 50, "end": 250})).unwrap();
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(ids[0], 50, 50), (ids[1], 100, 100), (ids[2], 200, 100)]);
|
||||
assert_eq!(result["trimmed"], json!(1));
|
||||
engine.undo();
|
||||
assert_eq!(engine.session().track(track).unwrap().clips()[0].length, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_trim_skips_locked_clips() {
|
||||
let (session, track, ids) = three_clip_session();
|
||||
let mut engine = Engine::new(session);
|
||||
engine.execute("edit.trim_start_to_insertion", &json!({"tracks": [track.0], "at": 150})).unwrap();
|
||||
engine.execute("edit.trim_end_to_insertion", &json!({"tracks": [track.0], "at": 250})).unwrap();
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
assert_eq!(
|
||||
clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(),
|
||||
vec![(ids[0], 0, 100), (ids[1], 100, 100), (ids[2], 200, 100)],
|
||||
"only unlocked clips trim; the edit-locked and time-locked ones keep their extent"
|
||||
);
|
||||
}
|
||||
|
||||
/// A lock on one clip must not suppress eligible unlocked clips on the same track.
|
||||
#[test]
|
||||
fn insertion_trims_still_trim_unlocked_clips_beside_both_lock_kinds() {
|
||||
for (command, expected_start) in [("edit.trim_start_to_insertion", 50), ("edit.trim_end_to_insertion", 0)] {
|
||||
let (mut session, track, ids) = three_clip_session();
|
||||
for clip in &mut session.track_mut(track).unwrap().playlist_mut().unwrap().clips {
|
||||
clip.start = 0;
|
||||
}
|
||||
let before = session.clone();
|
||||
let mut engine = Engine::new(session);
|
||||
let result = engine.execute(command, &json!({"tracks": [track.0], "at": 50})).unwrap();
|
||||
assert_eq!(result["trimmed"], json!(1), "{command}");
|
||||
let clips = engine.session().track(track).unwrap().clips();
|
||||
let free = clips.iter().find(|c| c.id == ids[0]).unwrap();
|
||||
assert_eq!((free.start, free.length), (expected_start, 50), "{command}");
|
||||
for id in &ids[1..] {
|
||||
let locked = clips.iter().find(|c| c.id == *id).unwrap();
|
||||
assert_eq!((locked.start, locked.length), (0, 100), "{command}");
|
||||
}
|
||||
assert!(engine.undo());
|
||||
assert_eq!(engine.session(), &before, "{command} undo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
@@ -108,14 +110,28 @@ impl Sequence {
|
||||
|
||||
self.ctrls.sort_by_key(|c| c.tick);
|
||||
let mut kept: Vec<CtrlEvent> = Vec::with_capacity(self.ctrls.len());
|
||||
// The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found
|
||||
// without rescanning the tick's events (a tick can hold many thousands of distinct keys).
|
||||
// An entry from an earlier tick counts as absent; keeping the tick instead of clearing the
|
||||
// map per tick keeps the work per event constant after a dense tick.
|
||||
let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new();
|
||||
for c in self.ctrls.drain(..) {
|
||||
// Within a tick, replace an earlier event of the same channel+kind.
|
||||
// Only events at the same tick (the tail of `kept`) can match.
|
||||
let slot =
|
||||
kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && kind_key(k.kind) == kind_key(c.kind));
|
||||
match slot {
|
||||
Some(slot) => *slot = c,
|
||||
None => kept.push(c),
|
||||
match last_at.entry((c.channel, kind_key(c.kind))) {
|
||||
Entry::Occupied(mut e) => {
|
||||
let (tick, idx) = *e.get();
|
||||
match kept.get_mut(idx) {
|
||||
Some(slot) if tick == c.tick => *slot = c,
|
||||
_ => {
|
||||
e.insert((c.tick, kept.len()));
|
||||
kept.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
e.insert((c.tick, kept.len()));
|
||||
kept.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.ctrls = kept;
|
||||
|
||||
@@ -222,3 +222,72 @@ fn sequence_serde_round_trip() {
|
||||
let json = serde_json::to_string(&seq).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Sequence>(&json).unwrap(), seq);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_handles_a_large_same_tick_controller_group() {
|
||||
// Every channel x controller number at one tick, twice: the second copy of each wins. A
|
||||
// rescan of the tick's kept events per incoming event would make this quadratic.
|
||||
let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect();
|
||||
let ctrls = [0, 1]
|
||||
.iter()
|
||||
.flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass }))
|
||||
.collect();
|
||||
let mut seq = Sequence { notes: Vec::new(), ctrls };
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(seq.remove_duplicates(), keys.len());
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
assert_eq!(seq.ctrls.len(), keys.len());
|
||||
assert!(seq.ctrls.iter().all(|c| c.value == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() {
|
||||
// The straightforward per-tick rescan the indexed version replaced, kept as an oracle.
|
||||
fn reference(mut ctrls: Vec<CtrlEvent>) -> Vec<CtrlEvent> {
|
||||
ctrls.sort_by_key(|c| c.tick);
|
||||
let key = |k: CtrlKind| match k {
|
||||
CtrlKind::Cc(n) => (0, n),
|
||||
CtrlKind::PitchBend => (1, 0),
|
||||
CtrlKind::ChannelPressure => (2, 0),
|
||||
CtrlKind::PolyPressure(p) => (3, p),
|
||||
CtrlKind::Program => (4, 0),
|
||||
};
|
||||
let mut kept: Vec<CtrlEvent> = Vec::new();
|
||||
for c in ctrls {
|
||||
match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) {
|
||||
Some(slot) => *slot = c,
|
||||
None => kept.push(c),
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
// Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with
|
||||
// other keys) and across ticks; unsorted input from a fixed xorshift stream.
|
||||
let mut state = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
let kinds = |r: u64| match r % 5 {
|
||||
0 => CtrlKind::Cc((r >> 8) as u8 % 4),
|
||||
1 => CtrlKind::PitchBend,
|
||||
2 => CtrlKind::ChannelPressure,
|
||||
3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4),
|
||||
_ => CtrlKind::Program,
|
||||
};
|
||||
for round in 0..200 {
|
||||
let mut ctrls = Vec::new();
|
||||
for i in 0..(next() % 64) {
|
||||
let r = next();
|
||||
ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 });
|
||||
}
|
||||
let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() };
|
||||
let removed = seq.remove_duplicates();
|
||||
let expected = reference(ctrls.clone());
|
||||
assert_eq!(seq.ctrls, expected, "round {round}");
|
||||
assert_eq!(removed, ctrls.len() - expected.len(), "round {round}");
|
||||
}
|
||||
}
|
||||
|
||||
+136
-21
@@ -1,4 +1,6 @@
|
||||
use super::{AutoParam, Samples, Session, TICKS_PER_QUARTER, Track, Transport, db_to_gain};
|
||||
use soundcraft_dsp::MIN_DB;
|
||||
use soundcraft_model::AutomationLane;
|
||||
|
||||
pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport {
|
||||
let sr = s.sample_rate;
|
||||
@@ -21,15 +23,146 @@ pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording:
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn volume_db_at(t: &Track, pos: Samples) -> f32 {
|
||||
/// One term of a fader's level in dB: a fixed value, or an automation lane (with its default).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum Level<'a> {
|
||||
Fixed(f32),
|
||||
Lane(&'a AutomationLane, f32),
|
||||
}
|
||||
|
||||
impl Level<'_> {
|
||||
fn at(&self, pos: Samples) -> f32 {
|
||||
match self {
|
||||
Level::Fixed(v) => *v,
|
||||
Level::Lane(l, default) => l.value_at(pos, *default),
|
||||
}
|
||||
}
|
||||
|
||||
/// The lane's first breakpoint after `pos`. Up to it, the level is linear in time.
|
||||
fn next_point(&self, pos: Samples) -> Option<Samples> {
|
||||
match self {
|
||||
Level::Fixed(_) => None,
|
||||
Level::Lane(l, _) => l.points.get(l.points.partition_point(|p| p.at <= pos)).map(|p| p.at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A track's volume: its automation lane when the mode reads it, else the fader.
|
||||
pub(crate) fn volume_level(t: &Track) -> Level<'_> {
|
||||
if t.mixer.automation_mode.reads()
|
||||
&& t.mixer.automation_mode != soundcraft_model::AutomationMode::Write
|
||||
&& let Some(l) = t.lane(&AutoParam::Volume)
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
return l.value_at(pos, t.mixer.volume_db);
|
||||
return Level::Lane(l, t.mixer.volume_db);
|
||||
}
|
||||
t.mixer.volume_db
|
||||
Level::Fixed(t.mixer.volume_db)
|
||||
}
|
||||
|
||||
/// Calls `f(i, j, d0, d1)` for each stretch `i..=j` of the block at `pos` over which the sum of
|
||||
/// `levels` is linear in dB, with its values at `i` and `j`. Lanes are linear between breakpoints,
|
||||
/// so a stretch ends at the next breakpoint or at the block's last frame.
|
||||
fn for_each_segment(levels: &[Level<'_>], pos: Samples, frames: usize, mut f: impl FnMut(usize, usize, f32, f32)) {
|
||||
let db = |at: Samples| levels.iter().map(|l| l.at(at)).sum::<f32>();
|
||||
let last = frames.saturating_sub(1);
|
||||
let mut i = 0usize;
|
||||
while i < frames {
|
||||
let at = pos.saturating_add(i as i64);
|
||||
let j = levels
|
||||
.iter()
|
||||
.filter_map(|l| l.next_point(at))
|
||||
.min()
|
||||
.map_or(last, |k| usize::try_from(k.saturating_sub(pos)).map_or(last, |j| j.min(last)));
|
||||
f(i, j, db(at), db(pos.saturating_add(j as i64)));
|
||||
i = j + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Multiplies `x` by a gain that goes from `d0` dB at its first sample to `d1` dB at its last,
|
||||
/// linearly in dB, capped at `max_db` and silent at or below [`MIN_DB`] (or NaN), as `db_to_gain`
|
||||
/// would give it sample by sample. Linear in dB is geometric in gain, so the ramp steps by one ratio
|
||||
/// instead of converting every sample; the ends are converted exactly.
|
||||
pub(crate) fn scale(x: &mut [f32], d0: f32, d1: f32, max_db: f32) {
|
||||
let Some(n) = x.len().checked_sub(1) else { return };
|
||||
// Match db_to_gain's ceiling even when the caller requests no fader cap.
|
||||
let max_db = max_db.min(200.0);
|
||||
if d0.is_nan() || d1.is_nan() {
|
||||
x.fill(0.0);
|
||||
return;
|
||||
}
|
||||
if d0 == d1 || n == 0 {
|
||||
let g = gain(d0, max_db);
|
||||
x.iter_mut().for_each(|v| *v *= g);
|
||||
return;
|
||||
}
|
||||
// Extreme finite trim values can underflow or overflow the geometric recurrence.
|
||||
// Convert those rare ramps directly so an inaudible prefix can recover normally.
|
||||
if !(-1000.0..=1000.0).contains(&d0) || !(-1000.0..=1000.0).contains(&d1) {
|
||||
for (i, v) in x.iter_mut().enumerate() {
|
||||
let t = i as f64 / n as f64;
|
||||
let db = if i == 0 {
|
||||
f64::from(d0)
|
||||
} else if i == n {
|
||||
f64::from(d1)
|
||||
} else {
|
||||
f64::from(d0) * (1.0 - t) + f64::from(d1) * t
|
||||
};
|
||||
*v *= gain(db as f32, max_db);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let step = (f64::from(d1) - f64::from(d0)) / n as f64;
|
||||
let ratio = 10f64.powf(step / 20.0);
|
||||
let (floor, cap) = (f64::from(MIN_DB), f64::from(max_db));
|
||||
let gcap = f64::from(db_to_gain(max_db));
|
||||
let (mut d, mut g) = (f64::from(d0), 10f64.powf(f64::from(d0) / 20.0));
|
||||
if let Some((first, rest)) = x.split_first_mut() {
|
||||
*first *= gain(d0, max_db);
|
||||
if let Some((last, mid)) = rest.split_last_mut() {
|
||||
for v in mid {
|
||||
d += step;
|
||||
g *= ratio;
|
||||
let gd = if d.is_nan() || d <= floor {
|
||||
0.0
|
||||
} else if d >= cap {
|
||||
gcap
|
||||
} else {
|
||||
g
|
||||
};
|
||||
*v *= gd as f32;
|
||||
}
|
||||
*last *= gain(d1, max_db);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The gain of `db`, at most `max_db`.
|
||||
fn gain(db: f32, max_db: f32) -> f32 {
|
||||
if db.is_nan() { 0.0 } else { db_to_gain(db.min(max_db)) }
|
||||
}
|
||||
|
||||
/// Fader gain over the block at `pos`, read at every frame: multiplies `buf`'s channels by the
|
||||
/// gain of the sum of `levels` (dB, at most `max_db`).
|
||||
pub(crate) fn apply_gain(levels: &[Level<'_>], pos: Samples, frames: usize, max_db: f32, buf: &mut [Vec<f32>]) {
|
||||
for_each_segment(levels, pos, frames, |i, j, d0, d1| {
|
||||
for c in buf.iter_mut() {
|
||||
if let Some(x) = c.get_mut(i..=j) {
|
||||
scale(x, d0, d1, max_db);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// [`apply_gain`] from `src` into `dst`.
|
||||
pub(crate) fn copy_with_gain(levels: &[Level<'_>], pos: Samples, frames: usize, max_db: f32, src: &[Vec<f32>], dst: &mut [Vec<f32>]) {
|
||||
for_each_segment(levels, pos, frames, |i, j, d0, d1| {
|
||||
for (d, s) in dst.iter_mut().zip(src) {
|
||||
if let (Some(d), Some(s)) = (d.get_mut(i..=j), s.get(i..=j)) {
|
||||
d.copy_from_slice(s);
|
||||
scale(d, d0, d1, max_db);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
|
||||
@@ -69,21 +202,3 @@ pub(crate) fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct GainRamp {
|
||||
g0: f32,
|
||||
g1: f32,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl GainRamp {
|
||||
pub(crate) fn new(db0: f32, db1: f32, n: usize) -> Self {
|
||||
GainRamp { g0: db_to_gain(db0), g1: db_to_gain(db1), n }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn at(&self, i: usize) -> f32 {
|
||||
if self.n == 0 { self.g0 } else { self.g0 + (self.g1 - self.g0) * (i as f32 / self.n as f32) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ impl ClipFx {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the EQ or compressor is on: only they carry parameters that settle (a gain does not).
|
||||
pub(crate) fn has_smoothed_modules(&self) -> bool {
|
||||
self.eq.is_some() || self.comp.is_some()
|
||||
}
|
||||
pub(crate) fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
if let Some(eq) = &mut self.eq {
|
||||
eq.process(io, frames);
|
||||
|
||||
@@ -68,7 +68,23 @@ pub struct NewInstance {
|
||||
/// Creates a plugin (built-in or hosted), restores `state` (if any) and prepares it. Expensive
|
||||
/// for third-party plugins: in realtime use, call it on the control thread.
|
||||
pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_block: usize, ch: usize) -> Option<NewInstance> {
|
||||
create_instance_for(id, state, sample_rate, max_block, ch, false)
|
||||
}
|
||||
|
||||
/// [`create_instance`] for an engine that renders offline (`offline`) or in realtime: the plugin
|
||||
/// is told which before it is prepared ([`Plugin::set_offline`]).
|
||||
pub(crate) fn create_instance_for(
|
||||
id: &str,
|
||||
state: Option<&[u8]>,
|
||||
sample_rate: f32,
|
||||
max_block: usize,
|
||||
ch: usize,
|
||||
offline: bool,
|
||||
) -> Option<NewInstance> {
|
||||
let mut plugin = create_plugin(id)?;
|
||||
if offline {
|
||||
plugin.set_offline(true);
|
||||
}
|
||||
let refused_state = state.is_some_and(|st| !plugin.load_state(st));
|
||||
if refused_state {
|
||||
log::warn!("{id}: the plugin did not accept its stored state");
|
||||
|
||||
+13
-17
@@ -71,7 +71,7 @@ mod routing;
|
||||
mod strip;
|
||||
mod threads;
|
||||
|
||||
use automation::{GainRamp, pan_at, pan_segment, send_level, transport_at, volume_db_at};
|
||||
use automation::{copy_with_gain, pan_at, pan_segment, send_level, transport_at, volume_level};
|
||||
pub use instances::{INSTRUMENT_SLOT, InstanceSpec, NewInstance, PluginFailures, PreparedInstance, create_instance, instance_specs, is_third_party};
|
||||
pub use meter::StripMeter;
|
||||
use midi::sync_sample;
|
||||
@@ -144,6 +144,8 @@ pub struct MixEngine {
|
||||
pub metronome: bool,
|
||||
/// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`.
|
||||
external: bool,
|
||||
/// An offline render: the plugins `sync` creates are told so ([`Plugin::set_offline`]).
|
||||
offline: bool,
|
||||
/// Instances handed over by the host, waiting for `sync` to place them.
|
||||
adopt: HashMap<(TrackId, usize), PreparedInstance>,
|
||||
/// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]).
|
||||
@@ -177,6 +179,7 @@ impl MixEngine {
|
||||
recording: false,
|
||||
metronome: false,
|
||||
external: false,
|
||||
offline: false,
|
||||
adopt: HashMap::new(),
|
||||
retired: Vec::new(),
|
||||
work: Vec::new(),
|
||||
@@ -189,6 +192,11 @@ impl MixEngine {
|
||||
self.external = on;
|
||||
}
|
||||
|
||||
/// Offline render (a bounce): plugins created from now on are told they render offline.
|
||||
pub fn set_offline(&mut self, on: bool) {
|
||||
self.offline = on;
|
||||
}
|
||||
|
||||
/// Takes instances created off the audio thread; the next render's `sync` places them.
|
||||
/// An earlier instance waiting for the same slot is retired.
|
||||
pub fn adopt(&mut self, instances: Vec<PreparedInstance>) {
|
||||
@@ -271,7 +279,8 @@ impl MixEngine {
|
||||
if self.main.len() != main_ch {
|
||||
self.main = vec![vec![0.0; mb]; main_ch];
|
||||
}
|
||||
let mut cx = SlotCtx { sr: self.sample_rate, mb, external: self.external, adopt: &mut self.adopt, retired: &mut self.retired };
|
||||
let mut cx =
|
||||
SlotCtx { sr: self.sample_rate, mb, external: self.external, offline: self.offline, adopt: &mut self.adopt, retired: &mut self.retired };
|
||||
let gone: Vec<TrackId> = self.strips.keys().filter(|id| s.track(**id).is_none()).copied().collect();
|
||||
for id in gone {
|
||||
self.meters.remove(&id);
|
||||
@@ -530,21 +539,8 @@ impl MixEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Match track faders: the next block's first sample must not ramp this one.
|
||||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||||
let v0 = volume_db_at(t, pos);
|
||||
let v1 = volume_db_at(t, last);
|
||||
let ramp = GainRamp::new(v0, v1, frames.saturating_sub(1));
|
||||
for (ch, src) in strip.buf.iter().enumerate().take(self.main.len()) {
|
||||
if let Some(m) = self.main.get_mut(ch) {
|
||||
for i in 0..frames {
|
||||
let g = ramp.at(i);
|
||||
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
|
||||
*d = x * g;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read at every sample, like track faders: a breakpoint acts on its own sample.
|
||||
copy_with_gain(&[volume_level(t)], pos, frames, f32::INFINITY, &strip.buf, &mut self.main);
|
||||
let m = self.meters.entry(t.id).or_default();
|
||||
m.measure(&self.main, frames, 0.0);
|
||||
m.plugin_failures = strip.failures;
|
||||
|
||||
@@ -46,12 +46,20 @@ pub(crate) fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize
|
||||
rtp("delay", 0.0).clamp(-1e7, 1e7) as i64,
|
||||
)
|
||||
};
|
||||
// The block in clip ticks, one tick wider on each side so rounding never drops an event.
|
||||
let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)
|
||||
..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);
|
||||
for n0 in &sequence.notes {
|
||||
let mut n = *n0;
|
||||
n.velocity = (i64::from(n.velocity) + dv).clamp(1, 127) as u8;
|
||||
n.pitch = (i64::from(n.pitch) + dp).clamp(0, 127) as u8;
|
||||
n.length = ((n.length as f64 * dur) as i64).max(1);
|
||||
n.start = n.start.saturating_add(delay).max(0);
|
||||
// A note whose on and off both fall outside the block sends nothing: skip it before
|
||||
// converting its positions, so a long sequence costs a comparison per note per block.
|
||||
if n.start > *near.end() || n.start.saturating_add(n.length) < *near.start() {
|
||||
continue;
|
||||
}
|
||||
let on = s.tempo.tick_to_samples(base.saturating_add(n.start), sr);
|
||||
let off = s.tempo.tick_to_samples(base.saturating_add(n.start).saturating_add(n.length), sr).min(clip.end());
|
||||
if on >= clip.end() {
|
||||
@@ -68,8 +76,6 @@ pub(crate) fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize
|
||||
held.retain(|p| *p != n.pitch);
|
||||
}
|
||||
}
|
||||
let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)
|
||||
..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);
|
||||
// Events before tick 0 are what a start trim hid: they don't play.
|
||||
for c in sequence.ctrls.iter().filter(|c| c.tick >= 0) {
|
||||
let tick = c.tick.saturating_add(delay).max(0);
|
||||
|
||||
@@ -57,7 +57,8 @@ impl RangeRender {
|
||||
/// Renders the main mix over `range`, or `bus` when one is named.
|
||||
pub fn new(s: &Session, bus: Option<BusId>, range: Range, block: usize) -> Self {
|
||||
let nch = source_channels(s, bus);
|
||||
let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||||
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||||
eng.set_offline(true);
|
||||
let tmp = vec![vec![0.0f32; eng.max_block()]; nch];
|
||||
let len = if nch == 0 { 0 } else { usize::try_from(range.len().max(0)).unwrap_or(0) };
|
||||
RangeRender { eng, bus, tmp, start: range.start, done: 0, total: len, latency: 0 }
|
||||
@@ -131,33 +132,85 @@ pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Block size for clip effects in [`render_clips`].
|
||||
const CLIP_FX_BLOCK: usize = 4096;
|
||||
/// Most a clip effect keeps rendering past its clip's end in [`render_clips`], in seconds.
|
||||
const MAX_CLIP_FX_TAIL_SECONDS: f64 = 10.0;
|
||||
/// Silent lead-in run before a clip with effects so smoothed parameters (EQ) settle, in seconds
|
||||
/// (the smoothers settle within ~0.3 s).
|
||||
const CLIP_FX_SETTLE_SECONDS: f64 = 1.0;
|
||||
/// Frames an effect advances its smoothed parameters by at a time (the EQ's chunk). Processing starts on a
|
||||
/// multiple of it from the selection's start, as a render of the whole selection would, so the smoothers
|
||||
/// step at the same samples.
|
||||
const CLIP_FX_CHUNK: usize = 32;
|
||||
/// A tail block whose peak is below this (-180 dBFS) ends a clip effect's tail.
|
||||
const CLIP_FX_TAIL_FLOOR: f32 = 1e-9;
|
||||
|
||||
/// Render only one track's clips (no mixer processing) — Consolidate.
|
||||
pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
|
||||
let Some(t) = s.track(track) else { return Vec::new() };
|
||||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1));
|
||||
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
|
||||
let mut scratch = Vec::new();
|
||||
// One reusable block for every clip with effects.
|
||||
let mut block: Vec<Vec<f32>> = Vec::new();
|
||||
let max_tail = s.sample_rate.samples(MAX_CLIP_FX_TAIL_SECONDS).max(0);
|
||||
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
|
||||
match clip_fx_params(s, c.id.0) {
|
||||
None => render_clip(s, c, range.start, len, &mut out, &mut scratch),
|
||||
Some(params) => {
|
||||
let mut tmp = vec![vec![0.0f32; len]; out.len()];
|
||||
render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
|
||||
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len());
|
||||
if block.len() != out.len() {
|
||||
block = vec![vec![0.0f32; CLIP_FX_BLOCK]; out.len()];
|
||||
}
|
||||
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, out.len());
|
||||
fx.configure(params);
|
||||
let mut done = 0;
|
||||
while done < len {
|
||||
let n = (len - done).min(4096);
|
||||
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch.get(done..done + n).map(<[f32]>::to_vec).unwrap_or_default()).collect();
|
||||
// The effects start from silence, so nothing comes out before the clip starts:
|
||||
// process from there through the clip and a bounded tail, not the whole selection.
|
||||
let offset = |at: Samples| usize::try_from(at.saturating_sub(range.start).max(0)).unwrap_or(usize::MAX).min(len);
|
||||
let first = offset(c.start);
|
||||
let clip_end = offset(c.end());
|
||||
let tail_end = offset(c.end().saturating_add(max_tail));
|
||||
// Smoothed parameters start at their defaults; settle them on silence, as the
|
||||
// full-span render did, so the clip opens at the configured setting.
|
||||
// (the clip's own samples start inside the first chunk when `first` is not on the grid)
|
||||
let grid = first - first % CLIP_FX_CHUNK;
|
||||
let mut lead = grid.min(usize::try_from(s.sample_rate.samples(CLIP_FX_SETTLE_SECONDS).max(0)).unwrap_or(0));
|
||||
lead -= lead % CLIP_FX_CHUNK;
|
||||
// a gain-only effect has no smoother to settle
|
||||
if !fx.has_smoothed_modules() {
|
||||
lead = 0;
|
||||
}
|
||||
while lead > 0 {
|
||||
let n = lead.min(CLIP_FX_BLOCK);
|
||||
for ch in &mut block {
|
||||
ch.fill(0.0);
|
||||
}
|
||||
fx.process(&mut block, n);
|
||||
lead -= n;
|
||||
}
|
||||
let mut done = grid;
|
||||
while done < len {
|
||||
let n = (len - done).min(CLIP_FX_BLOCK);
|
||||
for ch in &mut block {
|
||||
ch.fill(0.0);
|
||||
}
|
||||
if done < clip_end {
|
||||
render_clip(s, c, range.start.saturating_add(done as i64), n, &mut block, &mut scratch);
|
||||
}
|
||||
fx.process(&mut block, n);
|
||||
let mut peak = 0.0f32;
|
||||
for (o, b) in out.iter_mut().zip(block.iter()) {
|
||||
if let Some(dst) = o.get_mut(done..done + n) {
|
||||
for (x, y) in dst.iter_mut().zip(b.iter()) {
|
||||
*x += *y;
|
||||
peak = peak.max(y.abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
done += n;
|
||||
if done >= clip_end && (done >= tail_end || peak < CLIP_FX_TAIL_FLOOR) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+108
-24
@@ -46,7 +46,8 @@ pub(crate) fn processing_order(s: &Session) -> Vec<usize> {
|
||||
} else {
|
||||
// A cycle keeps the existing bounded ordering for the entire graph, including any
|
||||
// independent acyclic branches. Feedback does not have a finite longest-path depth.
|
||||
auxes.sort_by_key(|&i| bounded_aux_depth(s, i));
|
||||
let depths = bounded_aux_depths(s);
|
||||
auxes.sort_by_key(|&i| depths.get(i).copied().unwrap_or(0));
|
||||
}
|
||||
order.extend(auxes);
|
||||
order
|
||||
@@ -115,32 +116,38 @@ fn aux_output_busses(t: &Track) -> impl Iterator<Item = BusId> + '_ {
|
||||
.filter_map(|route| if let Route::Bus(bus) = route { Some(*bus) } else { None })
|
||||
}
|
||||
|
||||
/// The previous bounded depth calculation, retained unchanged for any cyclic graph.
|
||||
fn bounded_aux_depth(s: &Session, i: usize) -> usize {
|
||||
let mut d = 0;
|
||||
let mut cur = vec![i];
|
||||
for _ in 0..8 {
|
||||
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
|
||||
let feeders: Vec<usize> = auxes_feeding(s, &inputs);
|
||||
if feeders.is_empty() {
|
||||
/// Hop cap for the bounded depth used when the aux graph has a cycle.
|
||||
const MAX_BOUNDED_DEPTH: usize = 8;
|
||||
|
||||
/// For each track, the bounded depth used for a cyclic graph: the length of the longest chain of
|
||||
/// Aux/Folder feeders into it (through a bus its input reads and the feeder's output or a send
|
||||
/// writes), capped at [`MAX_BOUNDED_DEPTH`] hops. One pass per hop over the tracks and their bus
|
||||
/// writes, so the cost is linear in the routing, not a fresh feeder search per track per
|
||||
/// sort comparison.
|
||||
fn bounded_aux_depths(s: &Session) -> Vec<usize> {
|
||||
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
|
||||
// Tracks with a feeder chain of the current length ending at them (length 0: every aux).
|
||||
let mut reached: Vec<bool> = s.tracks.iter().map(is_aux).collect();
|
||||
let mut depths = vec![0usize; s.tracks.len()];
|
||||
let mut fed = std::collections::HashSet::<BusId>::new();
|
||||
for hop in 1..=MAX_BOUNDED_DEPTH {
|
||||
fed.clear();
|
||||
for (t, _) in s.tracks.iter().zip(&reached).filter(|(t, r)| **r && is_aux(t)) {
|
||||
fed.extend(aux_output_busses(t));
|
||||
}
|
||||
let mut any = false;
|
||||
for ((t, r), d) in s.tracks.iter().zip(reached.iter_mut()).zip(depths.iter_mut()) {
|
||||
*r = matches!(&t.mixer.input, Route::Bus(b) if fed.contains(b));
|
||||
if *r {
|
||||
*d = hop;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
break;
|
||||
}
|
||||
d += 1;
|
||||
cur = feeders;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
|
||||
s.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
|
||||
.filter(|(_, t)| {
|
||||
inputs.iter().any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
depths
|
||||
}
|
||||
|
||||
/// Implicit solo: an aux is not muted when a soloed track feeds its input bus.
|
||||
@@ -204,4 +211,81 @@ mod processing_order_tests {
|
||||
// Both deepest independent returns tie with the cycle at the legacy eight-hop cap.
|
||||
assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 0, 1, 10]);
|
||||
}
|
||||
|
||||
/// The per-track feeder search `bounded_aux_depths` replaced, kept as its reference.
|
||||
fn reference_depth(s: &Session, i: usize) -> usize {
|
||||
let mut d = 0;
|
||||
let mut cur = vec![i];
|
||||
for _ in 0..MAX_BOUNDED_DEPTH {
|
||||
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
|
||||
let feeders: Vec<usize> = s
|
||||
.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
|
||||
.filter(|(_, t)| {
|
||||
inputs
|
||||
.iter()
|
||||
.any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if feeders.is_empty() {
|
||||
break;
|
||||
}
|
||||
d += 1;
|
||||
cur = feeders;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_depths_match_the_per_track_feeder_search() {
|
||||
// A small pseudo-random graph of auxes, folders and audio tracks with cycles, joins,
|
||||
// sends and unrouted inputs.
|
||||
let mut s = Session::default();
|
||||
let busses: Vec<_> = (0..6).map(|i| s.add_bus(&format!("B{i}"), ChannelFormat::Stereo)).collect();
|
||||
let mut x = 12345u32;
|
||||
let mut next = |m: usize| {
|
||||
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
(x >> 16) as usize % m
|
||||
};
|
||||
for i in 0..30 {
|
||||
let kind = [TrackKind::Aux, TrackKind::Folder, TrackKind::Audio][next(3)];
|
||||
let id = s.add_track(kind, ChannelFormat::Stereo, None);
|
||||
let pick = |n: usize| if n < busses.len() { Route::Bus(busses[n]) } else { Route::Main };
|
||||
let (input, output, send) = (pick(next(8)), pick(next(8)), pick(next(8)));
|
||||
let t = s.track_mut(id).unwrap();
|
||||
t.mixer.input = input;
|
||||
t.mixer.output = output;
|
||||
if i % 2 == 0 {
|
||||
t.mixer.sends[0] = Some(SendSlot::new(send));
|
||||
}
|
||||
}
|
||||
let depths = bounded_aux_depths(&s);
|
||||
for (i, (t, depth)) in s.tracks.iter().zip(&depths).enumerate() {
|
||||
if matches!(t.kind, TrackKind::Aux | TrackKind::Folder) {
|
||||
assert_eq!(*depth, reference_depth(&s, i), "track {i}");
|
||||
}
|
||||
}
|
||||
assert!(depths.contains(&MAX_BOUNDED_DEPTH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_cyclic_graph_orders_quickly() {
|
||||
// Thousands of auxes all reading and writing one bus: every aux feeds every other.
|
||||
let mut s = Session::default();
|
||||
let bus = s.add_bus("Loop", ChannelFormat::Stereo);
|
||||
for _ in 0..3000 {
|
||||
let id = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
|
||||
let t = s.track_mut(id).unwrap();
|
||||
t.mixer.input = Route::Bus(bus);
|
||||
t.mixer.output = Route::Bus(bus);
|
||||
t.mixer.sends[0] = Some(SendSlot::new(Route::Bus(bus)));
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let order = processing_order(&s);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
assert_eq!(order, (0..3000).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
+137
-30
@@ -1,6 +1,6 @@
|
||||
use super::automation::{GainRamp, transport_at, volume_db_at};
|
||||
use super::automation::{Level, apply_gain, transport_at, volume_level};
|
||||
use super::clips::{ClipFx, clip_fx_params, render_clip};
|
||||
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance, is_third_party};
|
||||
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance_for, is_third_party};
|
||||
use super::midi::{chase_controllers, schedule_notes};
|
||||
use super::pan::{PanSpec, apply_matrix, build_matrix, identity_matrix};
|
||||
use super::routing::receives_solo;
|
||||
@@ -24,6 +24,9 @@ pub(crate) struct PluginSlot {
|
||||
pub(crate) plugin: Box<dyn Plugin>,
|
||||
/// Last values pushed to the plugin, in the insert's parameter-map order.
|
||||
applied: Vec<f32>,
|
||||
/// Scratch for `apply_params`: indexes of the track's automation lanes for this slot, kept
|
||||
/// so the audio thread reuses its allocation.
|
||||
lanes: Vec<usize>,
|
||||
pub(crate) failed: bool,
|
||||
}
|
||||
|
||||
@@ -157,6 +160,8 @@ pub(crate) struct SlotCtx<'a> {
|
||||
pub(crate) sr: f32,
|
||||
pub(crate) mb: usize,
|
||||
pub(crate) external: bool,
|
||||
/// The engine renders offline: plugins it creates are told so.
|
||||
pub(crate) offline: bool,
|
||||
pub(crate) adopt: &'a mut HashMap<(TrackId, usize), PreparedInstance>,
|
||||
pub(crate) retired: &'a mut Vec<Box<dyn Plugin>>,
|
||||
}
|
||||
@@ -189,9 +194,9 @@ impl SlotCtx<'_> {
|
||||
}
|
||||
} else {
|
||||
let state = if third { ins.state_bytes() } else { None };
|
||||
create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin)
|
||||
create_instance_for(&ins.plugin, state.as_deref(), self.sr, self.mb, ch, self.offline).map(|n| n.plugin)
|
||||
}?;
|
||||
Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false })
|
||||
Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +334,7 @@ fn render_instrument(s: &Session, t: &Track, strip: &mut Strip, blk: Block, tran
|
||||
fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
let mut gr = 0.0f32;
|
||||
let mut latency = 0usize;
|
||||
let mut latency = strip.instrument.as_ref().filter(|i| !i.failed).map_or(0, |i| i.plugin.latency());
|
||||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||||
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
||||
if !ins.active || ins.bypass || slot.failed {
|
||||
@@ -351,41 +356,28 @@ fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport)
|
||||
strip.latency = latency.min(MAX_LATENCY);
|
||||
}
|
||||
|
||||
/// Fader gain (volume, trim and VCA automation), ramped across the block.
|
||||
/// Fader gain (volume, trim and VCA automation), read at every sample of the block.
|
||||
fn apply_fader(s: &Session, t: &Track, strip: &mut Strip, blk: Block) {
|
||||
let Block { pos, frames, .. } = blk;
|
||||
// Endpoints are the first and last sample of this block. The next block starts on its own
|
||||
// first sample, so a breakpoint there does not ramp the block before it.
|
||||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||||
let mut v0 = volume_db_at(t, pos);
|
||||
let mut v1 = volume_db_at(t, last);
|
||||
// Trim automation (an offset on top of the volume curve).
|
||||
if let Some(l) = t
|
||||
let trim = t
|
||||
.automation
|
||||
.iter()
|
||||
.find(|l| matches!(&l.param, AutoParam::Plugin { slot: u8::MAX, param } if param == "trim"))
|
||||
.filter(|l| !l.points.is_empty())
|
||||
{
|
||||
v0 += l.value_at(pos, 0.0);
|
||||
v1 += l.value_at(last, 0.0);
|
||||
}
|
||||
.map_or(Level::Fixed(0.0), |l| Level::Lane(l, 0.0));
|
||||
// VCA master: its fader offsets the member's, and its mute mutes the member.
|
||||
let mut vca_level = Level::Fixed(0.0);
|
||||
if let Some(vid) = t.mixer.vca
|
||||
&& let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca)
|
||||
{
|
||||
v0 += volume_db_at(vca, pos);
|
||||
v1 += volume_db_at(vca, last);
|
||||
vca_level = volume_level(vca);
|
||||
if vca.mixer.mute {
|
||||
strip.muted = true;
|
||||
}
|
||||
}
|
||||
let (g0, g1) = if strip.muted { (-144.0, -144.0) } else { (v0.min(12.0), v1.min(12.0)) };
|
||||
let ramp = GainRamp::new(g0, g1, frames.saturating_sub(1));
|
||||
for c in strip.buf.iter_mut() {
|
||||
for (i, x) in c.iter_mut().enumerate().take(frames) {
|
||||
*x *= ramp.at(i);
|
||||
}
|
||||
}
|
||||
let levels = if strip.muted { [Level::Fixed(-144.0), Level::Fixed(0.0), Level::Fixed(0.0)] } else { [volume_level(t), trim, vca_level] };
|
||||
apply_gain(&levels, pos, frames, 12.0, &mut strip.buf);
|
||||
}
|
||||
|
||||
/// Track mute gates the audio and its pre-fader tap without splitting processor blocks.
|
||||
@@ -491,21 +483,40 @@ pub(crate) fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert
|
||||
let Ok(slot_u8) = u8::try_from(i) else {
|
||||
return apply_values(slot, ins, |_, v| v);
|
||||
};
|
||||
let automated = t.mixer.automation_mode.reads()
|
||||
&& t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());
|
||||
let lanes = &t.automation;
|
||||
let name = |j: usize| match lanes.get(j).map(|l| &l.param) {
|
||||
Some(AutoParam::Plugin { param, .. }) => param.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
// This slot's lanes, ordered by parameter name (the parameter map's order) and keeping only
|
||||
// the first lane per name (the one a lookup by name finds). Matching them to the parameters
|
||||
// in one merge costs O(lanes log lanes + params) per block, not O(lanes x params).
|
||||
let mut order = std::mem::take(&mut slot.lanes);
|
||||
order.clear();
|
||||
if t.mixer.automation_mode.reads() {
|
||||
order
|
||||
.extend(lanes.iter().enumerate().filter(|(_, l)| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8)).map(|(j, _)| j));
|
||||
if !order.iter().any(|&j| lanes.get(j).is_some_and(|l| !l.points.is_empty())) {
|
||||
order.clear();
|
||||
}
|
||||
order.sort_unstable_by(|&a, &b| name(a).cmp(name(b)).then(a.cmp(&b)));
|
||||
order.dedup_by(|later, kept| name(*later) == name(*kept));
|
||||
}
|
||||
let mut next = order.iter().copied().peekable();
|
||||
apply_values(slot, ins, |k, val| {
|
||||
if automated
|
||||
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
|
||||
while next.next_if(|&j| name(j) < k).is_some() {}
|
||||
if let Some(l) = next.peek().filter(|&&j| name(j) == k).and_then(|&j| lanes.get(j))
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
return l.value_at(pos, val);
|
||||
}
|
||||
val
|
||||
});
|
||||
slot.lanes = order;
|
||||
}
|
||||
|
||||
/// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time.
|
||||
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) {
|
||||
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, mut value: impl FnMut(&str, f32) -> f32) {
|
||||
if slot.applied.len() != ins.params.len() {
|
||||
slot.applied = vec![f32::NAN; ins.params.len()];
|
||||
}
|
||||
@@ -517,3 +528,99 @@ fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: im
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod apply_params_tests {
|
||||
use super::*;
|
||||
use soundcraft_model::{AutomationLane, Insert};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A built-in gain that logs every parameter set.
|
||||
struct Recorder {
|
||||
inner: Box<dyn Plugin>,
|
||||
sets: Arc<Mutex<Vec<(String, f32)>>>,
|
||||
}
|
||||
|
||||
impl Plugin for Recorder {
|
||||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||||
self.inner.info()
|
||||
}
|
||||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||||
self.inner.prepare(sr, mb, ch);
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||||
self.sets.lock().unwrap().push((id.to_string(), v));
|
||||
true
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.inner.param(id)
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
self.inner.process(io, frames);
|
||||
}
|
||||
}
|
||||
|
||||
fn recorder() -> (PluginSlot, Arc<Mutex<Vec<(String, f32)>>>) {
|
||||
let sets = Arc::new(Mutex::new(Vec::new()));
|
||||
let plugin = Box::new(Recorder { inner: soundcraft_dsp::create("gain").unwrap(), sets: Arc::clone(&sets) });
|
||||
(PluginSlot { id: "gain".into(), ch: 2, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false }, sets)
|
||||
}
|
||||
|
||||
fn lane(slot: u8, param: &str, point: Option<f32>) -> AutomationLane {
|
||||
let mut l = AutomationLane::new(AutoParam::Plugin { slot, param: param.into() });
|
||||
if let Some(v) = point {
|
||||
l.set_point(0, v);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_lane_per_param_of_this_slot_drives_it() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.automation = vec![
|
||||
lane(0, "c", None),
|
||||
lane(1, "a", Some(5.0)),
|
||||
lane(0, "b", Some(9.0)),
|
||||
lane(0, "c", Some(7.0)),
|
||||
lane(0, "zz", Some(1.0)),
|
||||
lane(0, "b", Some(8.0)),
|
||||
];
|
||||
let mut ins = Insert::new("gain");
|
||||
for (k, v) in [("a", 1.0), ("b", 2.0), ("c", 3.0), ("d", 4.0)] {
|
||||
ins.params.insert(k.into(), v);
|
||||
}
|
||||
let (mut slot, sets) = recorder();
|
||||
apply_params(&mut slot, &ins, tr, 0, 0);
|
||||
let got = sets.lock().unwrap().clone();
|
||||
let want: Vec<(String, f32)> = [("a", 1.0), ("b", 9.0), ("c", 3.0), ("d", 4.0)].iter().map(|(k, v)| (k.to_string(), *v)).collect();
|
||||
assert_eq!(got, want);
|
||||
// Unchanged values are not sent again.
|
||||
apply_params(&mut slot, &ins, tr, 0, 0);
|
||||
assert_eq!(sets.lock().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_params_and_lanes_apply_without_a_quadratic_search() {
|
||||
let n = 40_000;
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.automation = (0..n).rev().map(|i| lane(0, &format!("p{i:06}"), Some(i as f32))).collect();
|
||||
let mut ins = Insert::new("gain");
|
||||
for i in 0..n {
|
||||
ins.params.insert(format!("p{i:06}"), -1.0);
|
||||
}
|
||||
let (mut slot, sets) = recorder();
|
||||
let start = std::time::Instant::now();
|
||||
apply_params(&mut slot, &ins, tr, 0, 0);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
let got = sets.lock().unwrap();
|
||||
assert_eq!(got.len(), n);
|
||||
assert!(got.iter().all(|(k, v)| k == &format!("p{:06}", *v as usize)));
|
||||
}
|
||||
}
|
||||
|
||||
+309
-7
@@ -941,6 +941,84 @@ fn a_plugins_bogus_latency_is_capped() {
|
||||
assert_eq!((eng.track_latency, eng.latency), (MAX_LATENCY, 2 * MAX_LATENCY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hosted_instruments_latency_is_compensated() {
|
||||
// #1474: a synth that reports latency plays late. Its delay must count toward the mix
|
||||
// latency and align, the way an insert's does.
|
||||
struct LatentSynth {
|
||||
inner: Box<dyn Plugin>,
|
||||
n: usize,
|
||||
t: usize,
|
||||
pending: Option<usize>,
|
||||
}
|
||||
impl Plugin for LatentSynth {
|
||||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||||
self.inner.info()
|
||||
}
|
||||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||||
self.inner.prepare(sr, mb, ch);
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||||
self.inner.set_param(id, v)
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.inner.param(id)
|
||||
}
|
||||
fn note_on(&mut self, offset: usize, _: u8, _: u8) {
|
||||
self.pending = Some(self.t + offset + self.n);
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
for c in io.iter_mut() {
|
||||
for (i, x) in c.iter_mut().take(frames).enumerate() {
|
||||
*x = if Some(self.t + i) == self.pending { 0.25 } else { 0.0 };
|
||||
}
|
||||
}
|
||||
self.t += frames;
|
||||
}
|
||||
fn latency(&self) -> usize {
|
||||
self.n
|
||||
}
|
||||
}
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
let n = 500;
|
||||
let mut s = Session::default();
|
||||
s.edit.delay_compensation = true;
|
||||
let audio = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let mut click = vec![0.0f32; 48_000];
|
||||
click[24_000] = 0.25;
|
||||
s.pool.insert(SourceId(1474), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![click.clone(), click] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(audio).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "click", SourceId(1474), 0, 0, 48_000));
|
||||
let inst = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
|
||||
let mut seq = Sequence::default();
|
||||
seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 960, length: 240 });
|
||||
let mcid = s.new_clip_id();
|
||||
s.track_mut(inst).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(mcid, "note", 0, 48_000, seq));
|
||||
s.track_mut(inst).unwrap().instrument = Some(Insert::new("clap:test.latent"));
|
||||
let mut eng = MixEngine::new(48_000.0, 1024);
|
||||
eng.set_external_instances(true);
|
||||
eng.adopt(vec![PreparedInstance {
|
||||
track: inst,
|
||||
slot: INSTRUMENT_SLOT,
|
||||
id: "clap:test.latent".into(),
|
||||
ch: 2,
|
||||
plugin: Box::new(LatentSynth { inner: soundcraft_dsp::create("gain").unwrap(), n, t: 0, pending: None }),
|
||||
}]);
|
||||
let mut out = vec![vec![0.0; 1024]; 2];
|
||||
let mut rendered = Vec::new();
|
||||
for b in 0..48 {
|
||||
eng.render(&s, b * 1024, 1024, &mut out);
|
||||
rendered.extend_from_slice(&out[0]);
|
||||
}
|
||||
assert_eq!(eng.latency, n, "the hosted instrument's latency is part of the mix latency");
|
||||
let impulses: Vec<_> = rendered.iter().enumerate().filter(|(_, v)| v.abs() > 1e-6).map(|(i, _)| i).collect();
|
||||
assert_eq!(impulses, vec![24_000 + n], "both the audio click and instrument impulse align");
|
||||
assert!((rendered[24_000 + n] - 0.5).abs() < 1e-6, "the aligned impulses add, neither is missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_monitoring_replaces_clips() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
@@ -1197,14 +1275,238 @@ fn apply_matrix_span_matches_the_plain_per_sample_sum() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A volume lane through `points` (sample, dB).
|
||||
fn volume_lane(points: &[(i64, f32)]) -> soundcraft_model::AutomationLane {
|
||||
let mut l = soundcraft_model::AutomationLane::new(AutoParam::Volume);
|
||||
for &(at, db) in points {
|
||||
l.set_point(at, db);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// Volume automation is read at every sample: a breakpoint acts on its own sample, and between
|
||||
/// breakpoints the gain follows the lane (linear in dB), whatever size the render blocks are. A
|
||||
/// 480-sample crossfade used to be resolved once per block, so it moved by up to a block and left
|
||||
/// a hole or not depending on where the block edge fell.
|
||||
#[test]
|
||||
fn gain_ramp_matches_converting_both_ends_every_sample() {
|
||||
for (db0, db1, n) in [(-6.0f32, 3.0f32, 512usize), (0.0, 0.0, 1), (-144.0, 0.0, 300), (2.5, -60.0, 0), (f32::NAN, -3.0, 64), (-200.0, 12.0, 7)] {
|
||||
let ramp = crate::automation::GainRamp::new(db0, db1, n);
|
||||
let (g0, g1) = (db_to_gain(db0), db_to_gain(db1));
|
||||
for i in 0..=n {
|
||||
let want = if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) };
|
||||
assert_eq!(ramp.at(i).to_bits(), want.to_bits(), "{db0} -> {db1} dB, sample {i} of {n}");
|
||||
fn volume_automation_follows_its_breakpoints_in_any_block_size() {
|
||||
let (mut s, a) = session_with_dc(0.5, 8192);
|
||||
let b = add_dc_track(&mut s, 0.5, 8192);
|
||||
// A falls from 0 to -144 dB across 2760..3240 while B rises, and back across 5760..6240.
|
||||
let fall = volume_lane(&[(0, 0.0), (2760, 0.0), (3240, -144.0), (5760, -144.0), (6240, 0.0)]);
|
||||
let rise = volume_lane(&[(0, -144.0), (2760, -144.0), (3240, 0.0), (5760, 0.0), (6240, -144.0)]);
|
||||
// One track's level at 0 dB, through the pan law.
|
||||
let per_track = render_range(&s, Range::new(0, 64), 64)[0][0] / 2.0;
|
||||
s.track_mut(a).unwrap().automation.push(fall.clone());
|
||||
s.track_mut(b).unwrap().automation.push(rise.clone());
|
||||
let want = |i: i64| per_track * (db_to_gain(fall.value_at(i, 0.0)) + db_to_gain(rise.value_at(i, 0.0)));
|
||||
for block in [1024, 512, 333, 64] {
|
||||
let out = render_range(&s, Range::new(0, 8192), block);
|
||||
for (i, &x) in out[0].iter().enumerate() {
|
||||
let w = want(i as i64);
|
||||
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "block {block}, sample {i}: {x} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A master fader's breakpoint acts on its own sample too.
|
||||
#[test]
|
||||
fn master_volume_automation_follows_its_breakpoints_in_any_block_size() {
|
||||
let (mut s, _) = session_with_dc(0.5, 4096);
|
||||
let dry = render_range(&s, Range::new(0, 64), 64)[0][0];
|
||||
let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);
|
||||
let lane = volume_lane(&[(0, 0.0), (1500, 0.0), (1501, -144.0), (2600, -144.0), (2700, -6.0)]);
|
||||
s.track_mut(m).unwrap().automation.push(lane.clone());
|
||||
for block in [1024, 256, 100] {
|
||||
let out = render_range(&s, Range::new(0, 4096), block);
|
||||
for (i, &x) in out[0].iter().enumerate() {
|
||||
let w = dry * db_to_gain(lane.value_at(i as i64, 0.0));
|
||||
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "block {block}, sample {i}: {x} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `scale` gives what `db_to_gain` gives sample by sample, from a ramp's first sample to its last:
|
||||
/// silence at or below -144 dB, the cap, NaN, extreme values and one- or two-sample ramps.
|
||||
#[test]
|
||||
fn stepped_fader_ramps_match_converting_every_sample() {
|
||||
let cases: &[(&str, f32, f32, usize, f32)] = &[
|
||||
("down to silence", 0.0, -144.0, 480, 12.0),
|
||||
("up from silence", -144.0, 0.0, 480, 12.0),
|
||||
("across the cap", 6.0, 18.0, 300, 12.0),
|
||||
("below the floor and back", -200.0, -20.0, 512, 12.0),
|
||||
("flat", -6.0, -6.0, 1024, 12.0),
|
||||
("one sample", -6.0, 3.0, 1, 12.0),
|
||||
("two samples", -6.0, 3.0, 2, 12.0),
|
||||
("empty", 0.0, -6.0, 0, 12.0),
|
||||
("NaN start", f32::NAN, -3.0, 64, 12.0),
|
||||
("long ramp in one block", 0.0, -60.0, 16_384, 12.0),
|
||||
("recover from deep silence", -10_000.0, 0.0, 1024, 12.0),
|
||||
("descend from extreme trim", 10_000.0, -100.0, 1024, 12.0),
|
||||
("no cap", -12.0, 6.0, 1000, f32::INFINITY),
|
||||
("gain helper ceiling", 300.0, 0.0, 1024, f32::INFINITY),
|
||||
("extreme exact endpoint", -1e20, -20.0, 64, 12.0),
|
||||
("negative infinite start", f32::NEG_INFINITY, -20.0, 64, 12.0),
|
||||
("positive infinite end", -20.0, f32::INFINITY, 64, 12.0),
|
||||
("opposite infinities", f32::NEG_INFINITY, f32::INFINITY, 64, 12.0),
|
||||
];
|
||||
for &(name, d0, d1, n, max_db) in cases {
|
||||
let mut x = vec![1.0f32; n];
|
||||
crate::automation::scale(&mut x, d0, d1, max_db);
|
||||
for (k, &got) in x.iter().enumerate() {
|
||||
let at = if k == 0 {
|
||||
d0
|
||||
} else if k + 1 == n {
|
||||
d1
|
||||
} else {
|
||||
let t = k as f64 / (n - 1) as f64;
|
||||
(f64::from(d0) * (1.0 - t) + f64::from(d1) * t) as f32
|
||||
};
|
||||
let want = if d0.is_nan() || d1.is_nan() || at.is_nan() { 0.0 } else { db_to_gain(at.min(max_db)) };
|
||||
assert!((got - want).abs() <= 1e-5 * want.max(1e-6) || (got == 0.0 && want == 0.0), "{name}, sample {k} of {n}: {got} vs {want}");
|
||||
}
|
||||
if d0.is_nan() || d1.is_nan() {
|
||||
assert!(x.iter().all(|&g| g == 0.0), "{name}: a NaN end is silence");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(x.first().copied().unwrap_or(0.0), if n == 0 { 0.0 } else { db_to_gain(d0.min(max_db)) }, "{name}: first sample exact");
|
||||
if n > 1 {
|
||||
assert_eq!(x[n - 1], db_to_gain(d1.min(max_db)), "{name}: last sample exact");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume, trim and VCA lanes with breakpoints at different samples add up at every sample, a
|
||||
/// muted track stays silent, and a render that starts inside a ramp or has breakpoints on block
|
||||
/// edges reads the same curve.
|
||||
#[test]
|
||||
fn summed_fader_lanes_are_read_at_every_sample() {
|
||||
let (mut s, a) = session_with_dc(0.5, 12_288);
|
||||
let muted = add_dc_track(&mut s, 0.5, 12_288);
|
||||
let vca = s.add_track(TrackKind::Vca, ChannelFormat::Mono, None);
|
||||
let one_track = render_range(&s, Range::new(0, 64), 64)[0][0] / 2.0;
|
||||
s.track_mut(muted).unwrap().mixer.mute = true;
|
||||
let vol = volume_lane(&[(0, 0.0), (1024, 0.0), (2048, -30.0), (4096, -6.0)]);
|
||||
let mut trim = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: u8::MAX, param: "trim".into() });
|
||||
for (at, db) in [(0, 0.0), (1500, 0.0), (3000, 6.0)] {
|
||||
trim.set_point(at, db);
|
||||
}
|
||||
let vca_lane = volume_lane(&[(0, 0.0), (5120, 0.0), (7000, -12.0)]);
|
||||
let tr = s.track_mut(a).unwrap();
|
||||
tr.automation.push(vol.clone());
|
||||
tr.automation.push(trim.clone());
|
||||
tr.mixer.vca = Some(vca.0);
|
||||
s.track_mut(vca).unwrap().automation.push(vca_lane.clone());
|
||||
// dB at `i`, from the breakpoints themselves.
|
||||
let lin = |pts: &[(i64, f32)], i: i64| match pts.iter().position(|p| p.0 > i) {
|
||||
None => pts[pts.len() - 1].1,
|
||||
Some(0) => pts[0].1,
|
||||
Some(k) => pts[k - 1].1 + (pts[k].1 - pts[k - 1].1) * ((i - pts[k - 1].0) as f32 / (pts[k].0 - pts[k - 1].0) as f32),
|
||||
};
|
||||
let want = |i: i64| {
|
||||
let db = lin(&[(0, 0.0), (1024, 0.0), (2048, -30.0), (4096, -6.0)], i)
|
||||
+ lin(&[(0, 0.0), (1500, 0.0), (3000, 6.0)], i)
|
||||
+ lin(&[(0, 0.0), (5120, 0.0), (7000, -12.0)], i);
|
||||
one_track * db_to_gain(db.min(12.0))
|
||||
};
|
||||
for (start, block) in [(0i64, 1024), (0, 512), (1700, 333), (1024, 1024), (2047, 64)] {
|
||||
let out = render_range(&s, Range::new(start, 12_288), block);
|
||||
for (k, &x) in out[0].iter().enumerate() {
|
||||
let i = start + k as i64;
|
||||
let w = want(i);
|
||||
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "start {start}, block {block}, sample {i}: {x} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consolidate's clip-effect pass, processing every clip's effects across the whole selection
|
||||
/// (what `render_clips` did before it limited them to the clip and its tail).
|
||||
fn render_clips_full_span(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
|
||||
let t = s.track(track).unwrap();
|
||||
let len = range.len() as usize;
|
||||
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
|
||||
let mut scratch = Vec::new();
|
||||
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
|
||||
let params = crate::clips::clip_fx_params(s, c.id.0).unwrap();
|
||||
let mut tmp = vec![vec![0.0f32; len]; out.len()];
|
||||
crate::clips::render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
|
||||
let mut fx = crate::clips::ClipFx::new(48_000.0, tmp.len());
|
||||
fx.configure(params);
|
||||
for done in (0..len).step_by(4096) {
|
||||
let n = (len - done).min(4096);
|
||||
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch[done..done + n].to_vec()).collect();
|
||||
fx.process(&mut block, n);
|
||||
for (o, b) in out.iter_mut().zip(&block) {
|
||||
o[done..done + n].iter_mut().zip(b).for_each(|(x, y)| *x += y);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidate_clip_effects_cover_the_clip_and_its_tail() {
|
||||
let fx_sets: [&[(&str, f64)]; 3] =
|
||||
[&[("comp.lookahead", 5.0), ("comp.threshold", -20.0), ("gain", -6.0)], &[("eq.hpf_on", 1.0), ("gain", 3.0)], &[("eq.low_shelf_gain", 12.0)]];
|
||||
for (k, params) in fx_sets.iter().enumerate() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, 20_000, 4800));
|
||||
for (param, value) in params.iter() {
|
||||
s.edit.values.insert(format!("clip_fx.{}.{param}", cid.0), *value);
|
||||
}
|
||||
let range = Range::new(5_000, 100_000);
|
||||
let got = render_clips(&s, t, range);
|
||||
let want = render_clips_full_span(&s, t, range);
|
||||
assert_eq!(got.len(), want.len());
|
||||
assert!(want[0].iter().any(|x| x.abs() > 0.05), "the fixture should sound");
|
||||
if k == 0 {
|
||||
// The compressor's lookahead output after the clip's end is kept.
|
||||
assert!(got[0][20_000 + 4800 - 5_000 + 10].abs() > 1e-3);
|
||||
}
|
||||
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
|
||||
assert!((a - b).abs() < 1e-5, "set {k}, sample {i}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip that starts a few frames into the selection, while the EQ's smoothers are still settling: the
|
||||
/// effect's 32-frame chunks must line up with the full-span render's.
|
||||
#[test]
|
||||
fn consolidate_clip_effects_keep_the_reference_chunk_grid() {
|
||||
for offset in [1i64, 31, 32, 33, 1000] {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, offset, 4800));
|
||||
s.edit.values.insert(format!("clip_fx.{}.eq.low_shelf_gain", cid.0), 12.0);
|
||||
let range = Range::new(0, 20_000);
|
||||
let got = render_clips(&s, t, range);
|
||||
let want = render_clips_full_span(&s, t, range);
|
||||
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
|
||||
assert!((a - b).abs() < 1e-5, "clip at {offset}, sample {i}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidate_many_tiny_effect_clips_stays_fast() {
|
||||
// Thousands of one-sample clips with effects across a long selection: each one is processed
|
||||
// over its own span, not the whole selection.
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 1]] })));
|
||||
for i in 0..2000i64 {
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "x", SourceId(900), 0, i * 1000, 1));
|
||||
s.edit.values.insert(format!("clip_fx.{}.gain", cid.0), -6.0);
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let out = render_clips(&s, t, Range::new(0, 4_000_000));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
assert!((out[0][1000] - 0.5 * db_to_gain(-6.0)).abs() < 1e-6);
|
||||
assert_eq!(out[0][1001], 0.0);
|
||||
}
|
||||
|
||||
+26
-10
@@ -33,10 +33,10 @@ fn as_audio_thread(audio: bool, f: impl FnOnce()) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {
|
||||
use rayon::prelude::*;
|
||||
if work.len() >= 4 {
|
||||
// The workers render on behalf of this thread: on the audio thread, they are it too.
|
||||
let audio = on_audio_thread();
|
||||
work.par_iter_mut().for_each(|w| as_audio_thread(audio, || f(w)));
|
||||
// The audio thread is not a rayon worker, so it would park on a latch waiting for the shared
|
||||
// pool (also busy with offline work): it renders its strips itself.
|
||||
if work.len() >= 4 && !on_audio_thread() {
|
||||
work.par_iter_mut().for_each(|w| as_audio_thread(false, || f(w)));
|
||||
} else {
|
||||
work.iter_mut().for_each(f);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ pub(crate) fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Se
|
||||
work.iter_mut().for_each(f);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod audio_thread_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -67,7 +67,7 @@ mod audio_thread_tests {
|
||||
|
||||
/// What `on_audio_thread` says inside each strip job, for strips run from a thread that is
|
||||
/// (or is not) the audio thread.
|
||||
fn seen_by_strips(audio: bool) -> Vec<bool> {
|
||||
fn seen_by_strips(audio: bool) -> Result<Vec<bool>, &'static str> {
|
||||
std::thread::spawn(move || {
|
||||
if audio {
|
||||
mark_audio_thread();
|
||||
@@ -78,15 +78,31 @@ mod audio_thread_tests {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
*w = Some(on_audio_thread());
|
||||
});
|
||||
work.into_iter().map(|w| w.unwrap()).collect()
|
||||
work.into_iter().collect::<Option<Vec<_>>>().ok_or("a strip did not run")
|
||||
})
|
||||
.join()
|
||||
.unwrap()
|
||||
.map_err(|_| "the strip render thread panicked")?
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_rendered_for_the_audio_thread_count_as_the_audio_thread_and_only_then() {
|
||||
assert!(seen_by_strips(true).iter().all(|&rt| rt), "a worker rendering for the audio thread is on it");
|
||||
assert!(seen_by_strips(false).iter().all(|&rt| !rt), "the workers don't stay marked afterwards");
|
||||
assert_eq!(seen_by_strips(true), Ok(vec![true; 64]), "strips rendered for the audio thread are on it");
|
||||
assert_eq!(seen_by_strips(false), Ok(vec![false; 64]), "the workers don't stay marked afterwards");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_audio_thread_never_hands_strips_to_the_rayon_pool() {
|
||||
let on_pool = std::thread::spawn(|| {
|
||||
mark_audio_thread();
|
||||
let mut work = vec![false; 8];
|
||||
run_strips(&mut work, |w| {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
*w = rayon::current_thread_index().is_some();
|
||||
});
|
||||
work.into_iter().filter(|&p| p).count()
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!(on_pool, 0, "the audio thread must not block on the rayon pool");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,10 @@ impl AutomationLane {
|
||||
// Widen so far-apart breakpoints cannot overflow i64.
|
||||
let span = i128::from(b.at) - i128::from(a.at);
|
||||
let off = i128::from(at) - i128::from(a.at);
|
||||
a.value + (b.value - a.value) * (off as f64 / span as f64) as f32
|
||||
// Interpolate in f64: the f32 difference overflows for
|
||||
// opposite-sign values near f32::MAX.
|
||||
let (av, bv) = (f64::from(a.value), f64::from(b.value));
|
||||
(av + (bv - av) * (off as f64 / span as f64)) as f32
|
||||
}
|
||||
(Some(a), _) => a.value,
|
||||
_ => default,
|
||||
@@ -281,6 +284,17 @@ mod tests {
|
||||
assert!(w.is_finite() && (-10.0..=0.0).contains(&w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_at_opposite_sign_extremes_stays_finite() {
|
||||
let mut l = AutomationLane::new(AutoParam::Plugin { slot: 0, param: "Cutoff".into() });
|
||||
l.set_point(0, -3.0e38);
|
||||
l.set_point(1000, 3.0e38);
|
||||
let v = l.value_at(500, 0.0);
|
||||
assert!(v.is_finite() && v.abs() < 1.0e32, "mid value {v}");
|
||||
let w = l.value_at(1, 0.0);
|
||||
assert!(w.is_finite() && w < -2.9e38, "early value {w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_label_max_does_not_overflow() {
|
||||
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
|
||||
|
||||
@@ -502,9 +502,11 @@ pub struct Mixer {
|
||||
pub solo: bool,
|
||||
#[serde(default)]
|
||||
pub solo_safe: bool,
|
||||
#[serde(default)]
|
||||
/// Live record-enable state. Runtime-only: opening a session must never arm an input.
|
||||
#[serde(skip)]
|
||||
pub record_arm: bool,
|
||||
#[serde(default)]
|
||||
/// Live input-monitor state. Runtime-only: opening a session must never enable monitoring.
|
||||
#[serde(skip)]
|
||||
pub input_monitor: bool,
|
||||
#[serde(default)]
|
||||
pub phase_invert: bool,
|
||||
|
||||
+375
-4
@@ -5,7 +5,7 @@ use crate::{
|
||||
Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool,
|
||||
TRACK_COLORS, Track, TrackId, TrackKind, VideoSource,
|
||||
};
|
||||
use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat};
|
||||
use soundcraft_time::{FrameRate, GridScale, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat};
|
||||
|
||||
/// Native session file extension.
|
||||
pub const SESSION_EXTENSION: &str = "scraft";
|
||||
@@ -147,6 +147,7 @@ pub struct ZoomState {
|
||||
}
|
||||
|
||||
impl Default for ZoomState {
|
||||
/// Starts with a practical overview zoom and five reusable preset levels.
|
||||
fn default() -> Self {
|
||||
ZoomState { samples_per_px: 1024.0, scroll: 0, waveform_zoom: 1.0, presets: [64.0, 256.0, 1024.0, 4096.0, 16384.0] }
|
||||
}
|
||||
@@ -179,6 +180,15 @@ pub struct EditState {
|
||||
pub playhead: Samples,
|
||||
pub grid: GridValue,
|
||||
pub nudge: GridValue,
|
||||
/// Grid/Nudge time scales follow the main counter when enabled.
|
||||
#[serde(default)]
|
||||
pub grid_follow_main: bool,
|
||||
#[serde(default)]
|
||||
pub nudge_follow_main: bool,
|
||||
/// Grid lines and snapping use visible clip boundaries and memory locations while the numeric
|
||||
/// Grid value is retained for when this mode is left.
|
||||
#[serde(default)]
|
||||
pub grid_clips_markers: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub grid_lines: bool,
|
||||
pub main_counter: TimeFormat,
|
||||
@@ -244,6 +254,7 @@ fn yes() -> bool {
|
||||
}
|
||||
|
||||
impl Default for EditState {
|
||||
/// Starts the Edit window in Slip mode with independent Grid and Nudge scales.
|
||||
fn default() -> Self {
|
||||
EditState {
|
||||
tool: Tool::Smart,
|
||||
@@ -255,6 +266,9 @@ impl Default for EditState {
|
||||
playhead: 0,
|
||||
grid: GridValue::default(),
|
||||
nudge: GridValue::Seconds(1.0),
|
||||
grid_follow_main: false,
|
||||
nudge_follow_main: false,
|
||||
grid_clips_markers: false,
|
||||
grid_lines: true,
|
||||
main_counter: TimeFormat::MinSecs,
|
||||
sub_counter: Some(TimeFormat::Samples),
|
||||
@@ -490,6 +504,138 @@ impl Session {
|
||||
soundcraft_time::parse_position(text, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start())
|
||||
}
|
||||
|
||||
/// Whether the Edit window shows a track: unhidden, with every parent folder open.
|
||||
pub fn track_shown(&self, track: &Track) -> bool {
|
||||
if track.hidden {
|
||||
return false;
|
||||
}
|
||||
let mut folder = track.folder;
|
||||
for _ in 0..64 {
|
||||
let Some(parent) = folder.and_then(|id| self.track(id)) else { return true };
|
||||
if !parent.folder_open {
|
||||
return false;
|
||||
}
|
||||
folder = parent.folder;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Collects unique visible active-playlist clip boundaries and memory-location boundaries.
|
||||
fn clip_marker_grid_points(&self, keep: impl Fn(Samples) -> bool, excluded: &[ClipId], include_zero: bool) -> Vec<Samples> {
|
||||
let mut points = Vec::new();
|
||||
let excluded: std::collections::HashSet<ClipId> = excluded.iter().copied().collect();
|
||||
if include_zero && keep(0) {
|
||||
points.push(0);
|
||||
}
|
||||
for clip in
|
||||
self.tracks.iter().filter(|track| self.track_shown(track)).flat_map(|track| track.clips()).filter(|clip| !excluded.contains(&clip.id))
|
||||
{
|
||||
points.extend([clip.start, clip.end()].into_iter().filter(|point| keep(*point)));
|
||||
}
|
||||
for marker in &self.markers {
|
||||
if keep(marker.start) {
|
||||
points.push(marker.start);
|
||||
}
|
||||
if marker.end != marker.start && keep(marker.end) {
|
||||
points.push(marker.end);
|
||||
}
|
||||
}
|
||||
points.sort_unstable();
|
||||
points.dedup();
|
||||
points
|
||||
}
|
||||
|
||||
/// Sample-domain Timecode reference; Feet+Frames applies its origin in frames separately.
|
||||
fn grid_origin(&self) -> Samples {
|
||||
match self.edit.grid.scale() {
|
||||
GridScale::Timecode => self.timecode_start,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid lines for the current Grid value, including clip and marker boundaries.
|
||||
pub fn grid_lines(&self, start: Samples, end: Samples, max: usize) -> Vec<Samples> {
|
||||
if !self.edit.grid_clips_markers {
|
||||
if let GridValue::FeetFrames(value) = self.edit.grid {
|
||||
return value.lines(start, end, self.sample_rate, self.frame_rate, self.feet_frames_start(), max);
|
||||
}
|
||||
let origin = self.grid_origin();
|
||||
return self
|
||||
.edit
|
||||
.grid
|
||||
.lines(start.saturating_add(origin), end.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate, max)
|
||||
.into_iter()
|
||||
.map(|point| point.saturating_sub(origin))
|
||||
.collect();
|
||||
}
|
||||
let points = self.clip_marker_grid_points(|point| point >= start && point < end, &[], true);
|
||||
if points.len() <= max {
|
||||
return points;
|
||||
}
|
||||
match max {
|
||||
0 => Vec::new(),
|
||||
1 => points.first().copied().into_iter().collect(),
|
||||
_ => {
|
||||
let span = i128::from(end).saturating_sub(i128::from(start)).max(1);
|
||||
let mut bucket = None;
|
||||
points
|
||||
.into_iter()
|
||||
.filter(|point| {
|
||||
let next = i128::from(*point).saturating_sub(i128::from(start)).saturating_mul(max as i128) / span;
|
||||
if bucket == Some(next) {
|
||||
false
|
||||
} else {
|
||||
bucket = Some(next);
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current Grid line at or before `position`.
|
||||
pub fn grid_line_at_or_before(&self, position: Samples) -> Samples {
|
||||
self.grid_line_at_or_before_excluding(position, &[])
|
||||
}
|
||||
|
||||
/// Find the current Grid line at or before `position` without boundaries of `excluded` clips.
|
||||
pub fn grid_line_at_or_before_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples {
|
||||
if !self.edit.grid_clips_markers {
|
||||
if let GridValue::FeetFrames(value) = self.edit.grid {
|
||||
return value.line_at_or_before(position, self.sample_rate, self.frame_rate, self.feet_frames_start());
|
||||
}
|
||||
let origin = self.grid_origin();
|
||||
return self
|
||||
.edit
|
||||
.grid
|
||||
.line_at_or_before(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate)
|
||||
.saturating_sub(origin);
|
||||
}
|
||||
self.clip_marker_grid_points(|_| true, excluded, true).into_iter().take_while(|point| *point <= position).last().unwrap_or(position)
|
||||
}
|
||||
|
||||
/// Snap to the nearest line in the current Grid.
|
||||
pub fn snap_to_grid(&self, position: Samples) -> Samples {
|
||||
self.snap_to_grid_excluding(position, &[])
|
||||
}
|
||||
|
||||
/// Snap to the nearest current Grid line without using boundaries of `excluded` clips.
|
||||
pub fn snap_to_grid_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples {
|
||||
if !self.edit.grid_clips_markers {
|
||||
if let GridValue::FeetFrames(value) = self.edit.grid {
|
||||
return value.snap(position, self.sample_rate, self.frame_rate, self.feet_frames_start());
|
||||
}
|
||||
let origin = self.grid_origin();
|
||||
return self.edit.grid.snap(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate).saturating_sub(origin);
|
||||
}
|
||||
let mut points = self.clip_marker_grid_points(|_| true, excluded, false);
|
||||
if !points.is_empty() {
|
||||
points.push(0);
|
||||
}
|
||||
points.into_iter().min_by_key(|point| i128::from(*point).saturating_sub(i128::from(position)).abs()).unwrap_or(position)
|
||||
}
|
||||
|
||||
pub fn next_track_color(&self) -> [u8; 3] {
|
||||
let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count();
|
||||
TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128])
|
||||
@@ -578,13 +724,36 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Parse a session; decoded audio must be attached separately (`pool`).
|
||||
///
|
||||
/// The text is parsed straight into the document, never into a `serde_json::Value` tree
|
||||
/// first (which takes many times the file's size). Only the version is read before that.
|
||||
/// A field of the document given twice in one object is refused (`duplicate field`), as no
|
||||
/// writer repeats one. Keys of maps (plugin params, edit values, Auto Rename schemes, MIDI
|
||||
/// originals) and unknown keys keep the last value given, as before.
|
||||
pub fn from_json(text: &str) -> Result<Session, SessionError> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?;
|
||||
let ver = v.get("format_version").and_then(serde_json::Value::as_u64).ok_or_else(|| SessionError::Format("missing format_version".into()))?;
|
||||
/// The version alone: every other field is skipped, not kept.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Head {
|
||||
format_version: Option<serde_json::Number>,
|
||||
}
|
||||
// `Head` would also take a JSON array as its fields in order: only an object has a version.
|
||||
let object = text.trim_start_matches([' ', '\t', '\n', '\r']).starts_with('{');
|
||||
let version = match object.then(|| serde_json::from_str::<Head>(text)) {
|
||||
Some(Ok(head)) => head.format_version,
|
||||
// A version given twice is that error, not a missing version.
|
||||
Some(Err(e)) if e.to_string().starts_with("duplicate field") => return Err(SessionError::Format(e.to_string())),
|
||||
// Valid JSON of another shape (not an object, or a version that is not a number)
|
||||
// has no version; anything else is not JSON.
|
||||
_ => {
|
||||
serde_json::from_str::<serde::de::IgnoredAny>(text).map_err(|e| SessionError::Format(e.to_string()))?;
|
||||
None
|
||||
}
|
||||
};
|
||||
let ver = version.and_then(|v| v.as_u64()).ok_or_else(|| SessionError::Format("missing format_version".into()))?;
|
||||
if ver > u64::from(Self::FORMAT_VERSION) {
|
||||
return Err(SessionError::Version(u32::try_from(ver).unwrap_or(u32::MAX)));
|
||||
}
|
||||
let mut s: Session = serde_json::from_value(v).map_err(|e| SessionError::Format(e.to_string()))?;
|
||||
let mut s: Session = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?;
|
||||
s.sanitize();
|
||||
s.validate()?;
|
||||
Ok(s)
|
||||
@@ -762,6 +931,136 @@ mod tests {
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
/// Clips/Markers mode snaps and draws at clip and memory-location boundaries.
|
||||
#[test]
|
||||
fn clips_markers_grid_uses_visible_clip_edges_and_memory_locations() {
|
||||
let mut s = Session::default();
|
||||
let track = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let clip = Clip::midi(s.new_clip_id(), "Notes", 1_000, 2_000, soundcraft_midi::Sequence::default());
|
||||
let clip_id = clip.id;
|
||||
s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(clip);
|
||||
let hidden = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let hidden_clip = Clip::midi(s.new_clip_id(), "Hidden", 3_200, 100, soundcraft_midi::Sequence::default());
|
||||
if let Some(track) = s.track_mut(hidden) {
|
||||
track.hidden = true;
|
||||
track.playlist_mut().unwrap().clips.push(hidden_clip);
|
||||
}
|
||||
s.add_marker("Cue", MarkerKind::Marker, 2_500, 2_500);
|
||||
s.add_marker("Selection", MarkerKind::Selection, 4_000, 4_500);
|
||||
s.edit.grid_clips_markers = true;
|
||||
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 2_500, 3_000, 4_000, 4_500]);
|
||||
assert_eq!(s.grid_line_at_or_before(2_800), 2_500);
|
||||
assert_eq!(s.snap_to_grid(2_800), 3_000);
|
||||
assert_eq!(s.snap_to_grid(3_700), 4_000);
|
||||
assert_eq!(s.grid_lines(2_400, 4_100, 2), [2_500, 4_000]);
|
||||
assert_eq!(s.snap_to_grid(3_200), 3_000);
|
||||
assert_eq!(s.snap_to_grid_excluding(2_800, &[clip_id]), 2_500);
|
||||
|
||||
let folder = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
s.track_mut(track).unwrap().folder = Some(folder);
|
||||
s.track_mut(folder).unwrap().folder_open = false;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 2_500, 4_000, 4_500]);
|
||||
}
|
||||
|
||||
/// Open hidden folders keep their visible descendants eligible for lines and snapping.
|
||||
#[test]
|
||||
fn clips_markers_grid_keeps_visible_children_of_hidden_open_folders() {
|
||||
let mut s = Session::default();
|
||||
let outer = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
let inner = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
let child = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let clip = Clip::midi(s.new_clip_id(), "Visible", 1_000, 2_000, soundcraft_midi::Sequence::default());
|
||||
s.track_mut(inner).unwrap().folder = Some(outer);
|
||||
s.track_mut(inner).unwrap().hidden = true;
|
||||
s.track_mut(child).unwrap().folder = Some(inner);
|
||||
s.track_mut(child).unwrap().playlist_mut().unwrap().clips.push(clip);
|
||||
s.edit.grid_clips_markers = true;
|
||||
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 3_000]);
|
||||
assert_eq!(s.snap_to_grid(2_800), 3_000);
|
||||
assert_eq!(s.grid_line_at_or_before(2_800), 1_000);
|
||||
|
||||
s.track_mut(child).unwrap().hidden = true;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
|
||||
assert_eq!(s.snap_to_grid(2_800), 2_800);
|
||||
s.track_mut(child).unwrap().hidden = false;
|
||||
s.track_mut(inner).unwrap().folder_open = false;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
|
||||
s.track_mut(inner).unwrap().folder_open = true;
|
||||
s.track_mut(outer).unwrap().folder_open = false;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
|
||||
}
|
||||
|
||||
/// With no real Clips/Markers boundaries, snapping leaves the position alone while drawing
|
||||
/// retains the session-start line.
|
||||
#[test]
|
||||
fn empty_clips_markers_grid_does_not_snap_everything_to_zero() {
|
||||
let mut s = Session::default();
|
||||
s.edit.grid_clips_markers = true;
|
||||
assert_eq!(s.grid_lines(0, 1_000, 10), [0]);
|
||||
assert_eq!(s.snap_to_grid(678), 678);
|
||||
}
|
||||
|
||||
/// Frame-based grids share the same references as their displayed counters.
|
||||
#[test]
|
||||
fn frame_grids_apply_session_counter_origins() {
|
||||
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps30, timecode_start: 1_600, ..Session::default() };
|
||||
s.edit.grid = GridValue::Timecode(soundcraft_time::TimecodeValue::Second);
|
||||
assert_eq!(s.grid_line_at_or_before(47_000), 46_400);
|
||||
assert_eq!(s.snap_to_grid(46_400), 46_400);
|
||||
|
||||
s.edit.grid = GridValue::FeetFrames(soundcraft_time::FeetFramesValue::Foot);
|
||||
s.edit.values.insert(FEET_FRAMES_START.into(), 1.0);
|
||||
assert_eq!(s.grid_line_at_or_before(25_000), 24_000);
|
||||
assert_eq!(s.snap_to_grid(24_000), 24_000);
|
||||
}
|
||||
|
||||
/// Every Feet+Frames increment shares the parser's single rounding at fractional frame rates.
|
||||
#[test]
|
||||
fn feet_frames_grid_matches_parsed_positions_with_signed_frame_origins() {
|
||||
use soundcraft_time::FeetFramesValue;
|
||||
for rate in [FrameRate::Fps23_976, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: rate, ..Session::default() };
|
||||
for origin in [-17, -1, 1, 17] {
|
||||
s.edit.values.insert(FEET_FRAMES_START.into(), origin as f64);
|
||||
for value in FeetFramesValue::ALL {
|
||||
s.edit.grid = GridValue::FeetFrames(value);
|
||||
for feet in [-64, -2, 0, 1, 2, 4, 7, 12, 128] {
|
||||
let at = s.parse_position(&format!("{feet}+00"), TimeFormat::FeetFrames).unwrap();
|
||||
assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?} {value:?} origin {origin}, foot {feet}");
|
||||
assert_eq!(s.snap_to_grid(at), at);
|
||||
assert_eq!(s.grid_lines(at - 1, at + 2, 4), [at]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps29_97, ..Session::default() };
|
||||
s.edit.values.insert(FEET_FRAMES_START.into(), 1.0);
|
||||
s.edit.grid = GridValue::FeetFrames(FeetFramesValue::Foot);
|
||||
assert_eq!(s.snap_to_grid(49_649), 49_650);
|
||||
assert_eq!(s.format_position(49_650, TimeFormat::FeetFrames), "2+00");
|
||||
}
|
||||
|
||||
/// Drop-frame minute boundaries keep their counter alignment with signed session origins.
|
||||
#[test]
|
||||
fn drop_frame_grids_apply_session_origins_at_minute_boundaries() {
|
||||
for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1_800), (FrameRate::Fps59_94Drop, 3_600)] {
|
||||
for origin_frame in [-1, 1] {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let origin = rate.samples_at_frame(origin_frame, sr);
|
||||
let mut s = Session { sample_rate: sr, frame_rate: rate, timecode_start: origin, ..Session::default() };
|
||||
s.edit.grid = GridValue::Timecode(soundcraft_time::TimecodeValue::Second);
|
||||
for boundary in [-minute_frame, minute_frame] {
|
||||
let at = rate.samples_at_frame(boundary, sr) - origin;
|
||||
assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?}, origin {origin_frame}");
|
||||
assert_eq!(s.snap_to_grid(at + 1), at, "{rate:?}, origin {origin_frame}");
|
||||
assert_eq!(s.grid_lines(at - 1, at + 2, 10), [at]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_with_legacy_fit_height_loads() {
|
||||
let mut s = Session::default();
|
||||
@@ -775,6 +1074,31 @@ mod tests {
|
||||
assert_eq!(back.track(t).map(|tr| tr.height), Some(crate::TrackHeight::Medium));
|
||||
}
|
||||
|
||||
/// Session JSON omits live flags and ignores their values in legacy documents.
|
||||
#[test]
|
||||
fn live_input_states_are_never_saved_or_restored() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let track = s.track_mut(t).unwrap();
|
||||
track.mixer.mute = true;
|
||||
track.mixer.record_arm = true;
|
||||
track.mixer.input_monitor = true;
|
||||
|
||||
let mut value = serde_json::to_value(&s).unwrap();
|
||||
let mixer = value["tracks"][0]["mixer"].as_object_mut().unwrap();
|
||||
assert!(!mixer.contains_key("record_arm"));
|
||||
assert!(!mixer.contains_key("input_monitor"));
|
||||
|
||||
// Files written by older versions may still contain these keys; ignore them on input.
|
||||
mixer.insert("record_arm".into(), serde_json::Value::Bool(true));
|
||||
mixer.insert("input_monitor".into(), serde_json::Value::Bool(true));
|
||||
let loaded = Session::from_json(&value.to_string()).unwrap();
|
||||
let track = loaded.track(t).unwrap();
|
||||
assert!(track.mixer.mute, "persistent mixer state still loads");
|
||||
assert!(!track.mixer.record_arm);
|
||||
assert!(!track.mixer.input_monitor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_clip_round_trips_and_old_sessions_load() {
|
||||
let mut s = Session::default();
|
||||
@@ -864,6 +1188,53 @@ mod tests {
|
||||
assert!(matches!(Session::from_json(&v.to_string()), Err(SessionError::Version(999))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_version_is_checked_as_before() {
|
||||
// #892: the version is read without building the whole document as a tree; the errors
|
||||
// stay what they were.
|
||||
let format = |t: &str| match Session::from_json(t) {
|
||||
Err(SessionError::Format(m)) => m,
|
||||
other => panic!("{t}: {other:?}"),
|
||||
};
|
||||
for t in [
|
||||
"{}",
|
||||
"[]",
|
||||
"[5]",
|
||||
" \n[1, {}]",
|
||||
"\"x\"",
|
||||
"5",
|
||||
"null",
|
||||
r#"{"format_version":"1"}"#,
|
||||
r#"{"format_version":-1}"#,
|
||||
r#"{"format_version":1.5}"#,
|
||||
r#"{"format_version":null}"#,
|
||||
r#"{"format_version":[0,0,0]}"#,
|
||||
r#"{"name":"x","other":{"format_version":1}}"#,
|
||||
] {
|
||||
assert_eq!(format(t), "missing format_version", "{t}");
|
||||
}
|
||||
for t in ["not json", r#"{"format_version":1"#, r#"{"format_version":1} x"#, r#"{"format_version":"1", ]"#] {
|
||||
let want = serde_json::from_str::<serde_json::Value>(t).unwrap_err().to_string();
|
||||
assert_eq!(format(t), want, "{t}");
|
||||
}
|
||||
let mut v = serde_json::to_value(Session::default()).unwrap();
|
||||
for (ver, want) in [(serde_json::json!(2), 2), (serde_json::json!(1u64 << 40), u32::MAX)] {
|
||||
v["format_version"] = ver;
|
||||
assert_eq!(Session::from_json(&v.to_string()), Err(SessionError::Version(want)));
|
||||
}
|
||||
// A field given twice is refused by name, the version too.
|
||||
let twice = |key: &str| format(&format!(r#"{{"format_version":1,"{key}":"a","{key}":"b"}}"#));
|
||||
assert!(twice("name").starts_with("duplicate field `name` at line 1 column "), "{}", twice("name"));
|
||||
assert!(twice("format_version").starts_with("duplicate field `format_version` at line 1 column "), "{}", twice("format_version"));
|
||||
// A newer version is refused even when the rest would not parse.
|
||||
assert_eq!(Session::from_json(r#"{"format_version":7,"tracks":5}"#), Err(SessionError::Version(7)));
|
||||
// An older or the current version loads as it did.
|
||||
for ver in [0, 1] {
|
||||
v["format_version"] = serde_json::json!(ver);
|
||||
assert_eq!(Session::from_json(&v.to_string()).map(|s| s.format_version), Ok(ver));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_ids_are_rejected_on_load() {
|
||||
let mut s = Session::default();
|
||||
|
||||
+86
-37
@@ -83,8 +83,7 @@ impl TrackKind {
|
||||
}
|
||||
|
||||
/// Track heights (View › Track Height presets).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(from = "TrackHeightRepr")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize)]
|
||||
pub enum TrackHeight {
|
||||
/// Any height from Micro to Extreme, in points.
|
||||
Custom(u16),
|
||||
@@ -100,49 +99,69 @@ pub enum TrackHeight {
|
||||
Fit(u16),
|
||||
}
|
||||
|
||||
/// What a saved track height may look like: the current form, or the bare `"Fit"` string older sessions used.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum TrackHeightRepr {
|
||||
Current(TrackHeightCurrent),
|
||||
LegacyFit(LegacyFit),
|
||||
/// A saved track height: `"Micro"` … `"Extreme"` (or `{"Micro": null}` …), `{"Custom": points}`,
|
||||
/// `{"Fit": points}`, or the bare `"Fit"` older sessions used. Read as it comes, never buffered (as an
|
||||
/// untagged enum would), so anything else in a session file is refused at its first token.
|
||||
impl<'de> serde::Deserialize<'de> for TrackHeight {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
d.deserialize_any(HeightVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
enum LegacyFit {
|
||||
Fit,
|
||||
const HEIGHT_VARIANTS: &[&str] = &["Custom", "Micro", "Mini", "Small", "Medium", "Large", "Jumbo", "Extreme", "Fit"];
|
||||
|
||||
struct HeightVisitor;
|
||||
|
||||
impl HeightVisitor {
|
||||
/// The height a variant without a size names.
|
||||
fn preset(name: &str) -> Option<TrackHeight> {
|
||||
Some(match name {
|
||||
"Micro" => TrackHeight::Micro,
|
||||
"Mini" => TrackHeight::Mini,
|
||||
"Small" => TrackHeight::Small,
|
||||
"Medium" => TrackHeight::Medium,
|
||||
"Large" => TrackHeight::Large,
|
||||
"Jumbo" => TrackHeight::Jumbo,
|
||||
"Extreme" => TrackHeight::Extreme,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of `TrackHeight` that derives the current serialized form.
|
||||
#[derive(serde::Deserialize)]
|
||||
enum TrackHeightCurrent {
|
||||
Custom(u16),
|
||||
Micro,
|
||||
Mini,
|
||||
Small,
|
||||
Medium,
|
||||
Large,
|
||||
Jumbo,
|
||||
Extreme,
|
||||
Fit(u16),
|
||||
}
|
||||
impl<'de> serde::de::Visitor<'de> for HeightVisitor {
|
||||
type Value = TrackHeight;
|
||||
|
||||
impl From<TrackHeightRepr> for TrackHeight {
|
||||
fn from(repr: TrackHeightRepr) -> Self {
|
||||
match repr {
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Custom(p)) => TrackHeight::Custom(p),
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Micro) => TrackHeight::Micro,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Mini) => TrackHeight::Mini,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Small) => TrackHeight::Small,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Medium) => TrackHeight::Medium,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Large) => TrackHeight::Large,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Jumbo) => TrackHeight::Jumbo,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Extreme) => TrackHeight::Extreme,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Fit(p)) => TrackHeight::Fit(p),
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a track height")
|
||||
}
|
||||
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<TrackHeight, E> {
|
||||
match v {
|
||||
// Older sessions stored Fit with no size, drawn 88 points tall: the default Medium height.
|
||||
TrackHeightRepr::LegacyFit(LegacyFit::Fit) => TrackHeight::Medium,
|
||||
"Fit" => Ok(TrackHeight::Medium),
|
||||
"Custom" => Err(E::invalid_type(serde::de::Unexpected::UnitVariant, &"a height in points")),
|
||||
other => Self::preset(other).ok_or_else(|| E::unknown_variant(other, HEIGHT_VARIANTS)),
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<TrackHeight, A::Error> {
|
||||
use serde::de::Error as _;
|
||||
let Some(key) = map.next_key::<String>()? else { return Err(A::Error::invalid_length(0, &"one variant")) };
|
||||
let h = match key.as_str() {
|
||||
"Custom" => TrackHeight::Custom(map.next_value()?),
|
||||
// `{"Fit": null}` is the old size-less Fit too, as above.
|
||||
"Fit" => map.next_value::<Option<u16>>()?.map_or(TrackHeight::Medium, TrackHeight::Fit),
|
||||
other => {
|
||||
let h = Self::preset(other).ok_or_else(|| A::Error::unknown_variant(other, HEIGHT_VARIANTS))?;
|
||||
map.next_value::<()>()?;
|
||||
h
|
||||
}
|
||||
};
|
||||
if map.next_key::<serde::de::IgnoredAny>()?.is_some() {
|
||||
return Err(A::Error::invalid_length(2, &"one variant"));
|
||||
}
|
||||
Ok(h)
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackHeight {
|
||||
@@ -383,6 +402,36 @@ mod tests {
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>("{\"Custom\":50}").unwrap(), TrackHeight::Custom(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_saved_height_form_loads_and_nothing_else() {
|
||||
for h in TrackHeight::ALL.into_iter().chain([TrackHeight::Custom(50), TrackHeight::Fit(120)]) {
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>(&serde_json::to_string(&h).unwrap()).unwrap(), h);
|
||||
assert_eq!(serde_json::from_value::<TrackHeight>(serde_json::to_value(h).unwrap()).unwrap(), h);
|
||||
}
|
||||
for h in TrackHeight::ALL {
|
||||
let null = format!("{{{}:null}}", serde_json::to_string(&h).unwrap());
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>(&null).unwrap(), h, "{null}");
|
||||
}
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>(r#"{"Fit":null}"#).unwrap(), TrackHeight::Medium);
|
||||
assert!(serde_json::from_str::<TrackHeight>(r#"{"Custom":null}"#).is_err());
|
||||
for bad in
|
||||
[r#""Custom""#, r#""Huge""#, "7", "[]", "null", "{}", r#"{"Fit":"x"}"#, r#"{"Fit":70000}"#, r#"{"Large":1}"#, r#"{"Fit":1,"Fit":2}"#]
|
||||
{
|
||||
assert!(serde_json::from_str::<TrackHeight>(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_height_is_refused_at_its_first_token() {
|
||||
// #892: the height was read through an untagged enum, which buffers the whole value
|
||||
// first; a session could hold a huge array there. It is refused where it starts.
|
||||
for (open, rest) in [("[", "0,".repeat(100_000) + "0]"), (r#"{"Fit":["#, "0,".repeat(100_000) + "0]}")] {
|
||||
let text = format!("{open}{rest}");
|
||||
let err = serde_json::from_str::<TrackHeight>(&text).unwrap_err();
|
||||
assert!(err.is_data() && err.line() == 1 && err.column() <= open.len() + 1, "{open}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_track_defaults() {
|
||||
let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]);
|
||||
|
||||
+499
-20
@@ -1,11 +1,12 @@
|
||||
//! Grid and Nudge values: musical or absolute step sizes, grid lines and snapping.
|
||||
|
||||
use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, to_samples};
|
||||
use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, Timecode, to_samples};
|
||||
|
||||
/// Note values for Bars|Beats grids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum NoteValue {
|
||||
Bar,
|
||||
Whole,
|
||||
Half,
|
||||
Quarter,
|
||||
Eighth,
|
||||
@@ -15,8 +16,10 @@ pub enum NoteValue {
|
||||
}
|
||||
|
||||
impl NoteValue {
|
||||
pub const ALL: [NoteValue; 7] = [
|
||||
/// Every musical increment in menu order, from largest to smallest.
|
||||
pub const ALL: [NoteValue; 8] = [
|
||||
NoteValue::Bar,
|
||||
NoteValue::Whole,
|
||||
NoteValue::Half,
|
||||
NoteValue::Quarter,
|
||||
NoteValue::Eighth,
|
||||
@@ -24,9 +27,11 @@ impl NoteValue {
|
||||
NoteValue::ThirtySecond,
|
||||
NoteValue::SixtyFourth,
|
||||
];
|
||||
/// Returns the nominal tick length; bars are adjusted for the active meter when used.
|
||||
pub fn ticks(self) -> i64 {
|
||||
match self {
|
||||
NoteValue::Bar => TICKS_PER_QUARTER * 4,
|
||||
NoteValue::Whole => TICKS_PER_QUARTER * 4,
|
||||
NoteValue::Half => TICKS_PER_QUARTER * 2,
|
||||
NoteValue::Quarter => TICKS_PER_QUARTER,
|
||||
NoteValue::Eighth => TICKS_PER_QUARTER / 2,
|
||||
@@ -35,9 +40,11 @@ impl NoteValue {
|
||||
NoteValue::SixtyFourth => TICKS_PER_QUARTER / 16,
|
||||
}
|
||||
}
|
||||
/// Returns the menu label for this musical increment.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
NoteValue::Bar => "1 bar",
|
||||
NoteValue::Whole => "Whole note",
|
||||
NoteValue::Half => "1/2 note",
|
||||
NoteValue::Quarter => "1/4 note",
|
||||
NoteValue::Eighth => "1/8 note",
|
||||
@@ -48,26 +55,257 @@ impl NoteValue {
|
||||
}
|
||||
}
|
||||
|
||||
/// The time scale used by a Grid or Nudge value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum GridScale {
|
||||
BarsBeats,
|
||||
MinSecs,
|
||||
Timecode,
|
||||
FeetFrames,
|
||||
Samples,
|
||||
ClipsMarkers,
|
||||
}
|
||||
|
||||
impl GridScale {
|
||||
/// Numeric time scales shared by Grid and Nudge.
|
||||
pub const TIME: [GridScale; 5] = [GridScale::BarsBeats, GridScale::MinSecs, GridScale::Timecode, GridScale::FeetFrames, GridScale::Samples];
|
||||
|
||||
/// Returns the user-facing scale label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GridScale::BarsBeats => "Bars|Beats",
|
||||
GridScale::MinSecs => "Min:Secs",
|
||||
GridScale::Timecode => "Timecode",
|
||||
GridScale::FeetFrames => "Feet+Frames",
|
||||
GridScale::Samples => "Samples",
|
||||
GridScale::ClipsMarkers => "Clips/Markers",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
GridScale::BarsBeats => "bars_beats",
|
||||
GridScale::MinSecs => "min_secs",
|
||||
GridScale::Timecode => "timecode",
|
||||
GridScale::FeetFrames => "feet_frames",
|
||||
GridScale::Samples => "samples",
|
||||
GridScale::ClipsMarkers => "clips_markers",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<GridScale> {
|
||||
Self::TIME.into_iter().chain([GridScale::ClipsMarkers]).find(|scale| scale.id() == id || scale.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Timecode Grid/Nudge increments, ordered independently of the session frame rate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum TimecodeValue {
|
||||
Second,
|
||||
/// Five physical frames, independent of skipped drop-frame labels.
|
||||
FiveFrames,
|
||||
Frame,
|
||||
HalfFrame,
|
||||
QuarterFrame,
|
||||
SubFrame,
|
||||
}
|
||||
|
||||
impl TimecodeValue {
|
||||
/// Every Timecode increment in menu order, from largest to smallest.
|
||||
pub const ALL: [TimecodeValue; 6] = [
|
||||
TimecodeValue::Second,
|
||||
TimecodeValue::FiveFrames,
|
||||
TimecodeValue::Frame,
|
||||
TimecodeValue::HalfFrame,
|
||||
TimecodeValue::QuarterFrame,
|
||||
TimecodeValue::SubFrame,
|
||||
];
|
||||
|
||||
/// Returns the user-facing increment label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TimecodeValue::Second => "1 second",
|
||||
TimecodeValue::FiveFrames => "5 frames",
|
||||
TimecodeValue::Frame => "1 frame",
|
||||
TimecodeValue::HalfFrame => "1/2 frame",
|
||||
TimecodeValue::QuarterFrame => "1/4 frame",
|
||||
TimecodeValue::SubFrame => "1 subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TimecodeValue::Second => "second",
|
||||
TimecodeValue::FiveFrames => "5_frames",
|
||||
TimecodeValue::Frame => "frame",
|
||||
TimecodeValue::HalfFrame => "half_frame",
|
||||
TimecodeValue::QuarterFrame => "quarter_frame",
|
||||
TimecodeValue::SubFrame => "subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<TimecodeValue> {
|
||||
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// Returns the increment length in frames at the session rate.
|
||||
fn frames(self, rate: FrameRate) -> f64 {
|
||||
match self {
|
||||
TimecodeValue::Second => rate.nominal() as f64,
|
||||
TimecodeValue::FiveFrames => 5.0,
|
||||
TimecodeValue::Frame => 1.0,
|
||||
TimecodeValue::HalfFrame => 0.5,
|
||||
TimecodeValue::QuarterFrame => 0.25,
|
||||
TimecodeValue::SubFrame => 0.01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feet+Frames Grid/Nudge increments in physical frames, independent of drop-frame label skips.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FeetFramesValue {
|
||||
Foot,
|
||||
Frame,
|
||||
QuarterFrame,
|
||||
SubFrame,
|
||||
}
|
||||
|
||||
impl FeetFramesValue {
|
||||
/// Every Feet+Frames increment in menu order, from largest to smallest.
|
||||
pub const ALL: [FeetFramesValue; 4] = [FeetFramesValue::Foot, FeetFramesValue::Frame, FeetFramesValue::QuarterFrame, FeetFramesValue::SubFrame];
|
||||
|
||||
/// Returns the user-facing increment label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FeetFramesValue::Foot => "1 foot",
|
||||
FeetFramesValue::Frame => "1 frame",
|
||||
FeetFramesValue::QuarterFrame => "1/4 frame",
|
||||
FeetFramesValue::SubFrame => "1 subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
FeetFramesValue::Foot => "foot",
|
||||
FeetFramesValue::Frame => "frame",
|
||||
FeetFramesValue::QuarterFrame => "quarter_frame",
|
||||
FeetFramesValue::SubFrame => "subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<FeetFramesValue> {
|
||||
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// Returns the increment length in frames.
|
||||
fn frames(self) -> f64 {
|
||||
let (num, den) = self.frame_ratio();
|
||||
num as f64 / den as f64
|
||||
}
|
||||
|
||||
/// Exact numerator and denominator of the increment in physical frames.
|
||||
fn frame_ratio(self) -> (i128, i128) {
|
||||
match self {
|
||||
Self::Foot => (16, 1),
|
||||
Self::Frame => (1, 1),
|
||||
Self::QuarterFrame => (1, 4),
|
||||
Self::SubFrame => (1, 100),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the counter origin in frames before rounding once to the nearest sample.
|
||||
fn sample_line(self, index: i128, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
let (num, den) = self.frame_ratio();
|
||||
let (rate_num, rate_den) = rate.rational();
|
||||
let frames = index * num - i128::from(origin) * den;
|
||||
let samples = frames * i128::from(sr.hz().max(1)) * i128::from(rate_den);
|
||||
let divisor = den * i128::from(rate_num);
|
||||
let magnitude = (samples.abs() + divisor / 2) / divisor;
|
||||
let rounded = if samples < 0 { -magnitude } else { magnitude };
|
||||
i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX })
|
||||
}
|
||||
|
||||
/// Find a line index using exact frame arithmetic, allowing for whole-sample rounding.
|
||||
fn line_index(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> i128 {
|
||||
let (num, den) = self.frame_ratio();
|
||||
let (rate_num, rate_den) = rate.rational();
|
||||
let scale = i128::from(sr.hz().max(1)) * i128::from(rate_den);
|
||||
let frames = i128::from(position) * i128::from(rate_num) + i128::from(origin) * scale;
|
||||
let mut index = (frames * den).div_euclid(scale * num);
|
||||
if self.sample_line(index + 1, sr, rate, origin) <= position {
|
||||
index += 1;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// The Feet+Frames line at or before a sample, relative to the counter's frame origin.
|
||||
pub fn line_at_or_before(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
self.sample_line(self.line_index(position, sr, rate, origin), sr, rate, origin)
|
||||
}
|
||||
|
||||
/// The next distinct sample line; subframe increments may share a sample at low sample rates.
|
||||
fn next_line(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
self.sample_line(self.line_index(position, sr, rate, origin) + 1, sr, rate, origin).max(position.saturating_add(1))
|
||||
}
|
||||
|
||||
/// Snap against frame-origin lines, choosing the earlier line when distances are equal.
|
||||
pub fn snap(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
let lo = self.line_at_or_before(position, sr, rate, origin);
|
||||
let hi = self.next_line(lo, sr, rate, origin);
|
||||
if i128::from(position) - i128::from(lo) <= i128::from(hi) - i128::from(position) { lo } else { hi }
|
||||
}
|
||||
|
||||
/// Distinct Feet+Frames sample lines within `[start, end)`, capped at `max`.
|
||||
pub fn lines(self, start: Samples, end: Samples, sr: SampleRate, rate: FrameRate, origin: i64, max: usize) -> Vec<Samples> {
|
||||
let mut out = Vec::new();
|
||||
let mut line = self.line_at_or_before(start, sr, rate, origin);
|
||||
while line < end && out.len() < max {
|
||||
if line >= start {
|
||||
out.push(line);
|
||||
}
|
||||
let next = self.next_line(line, sr, rate, origin);
|
||||
if next <= line {
|
||||
break;
|
||||
}
|
||||
line = next;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid or nudge step.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum GridValue {
|
||||
/// Musical: note value, optionally dotted or triplet.
|
||||
Note { value: NoteValue, dotted: bool, triplet: bool },
|
||||
/// Absolute seconds (Min:Secs grids: 0.001 … 10 s).
|
||||
/// Absolute seconds (Min:Secs grids: 0.001 … 1 s).
|
||||
Seconds(f64),
|
||||
/// Frames at the session timecode rate.
|
||||
/// Kept for sessions and commands written before named timecode increments were added.
|
||||
Frames(i64),
|
||||
/// A named Timecode increment, including fractional frames and one second.
|
||||
Timecode(TimecodeValue),
|
||||
/// A Feet+Frames increment (one foot is 16 frames).
|
||||
FeetFrames(FeetFramesValue),
|
||||
/// Raw samples.
|
||||
Samples(i64),
|
||||
}
|
||||
|
||||
impl Default for GridValue {
|
||||
/// Starts on a straight sixteenth-note Bars|Beats grid.
|
||||
fn default() -> Self {
|
||||
GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl GridValue {
|
||||
/// Returns the user-facing label for this increment.
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
@@ -80,15 +318,47 @@ impl GridValue {
|
||||
}
|
||||
s
|
||||
}
|
||||
GridValue::Seconds(s) => format!("{s:.3} sec"),
|
||||
GridValue::Seconds(s) => {
|
||||
if *s >= 1.0 {
|
||||
format!("{s} second{}", if *s == 1.0 { "" } else { "s" })
|
||||
} else {
|
||||
format!("{} ms", (s * 1000.0).round() as i64)
|
||||
}
|
||||
}
|
||||
GridValue::Frames(f) => format!("{f} frame{}", if *f == 1 { "" } else { "s" }),
|
||||
GridValue::Samples(n) => format!("{n} samples"),
|
||||
GridValue::Timecode(value) => value.label().to_string(),
|
||||
GridValue::FeetFrames(value) => value.label().to_string(),
|
||||
GridValue::Samples(n) => format!("{n} sample{}", if *n == 1 { "" } else { "s" }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next larger or smaller value of the same kind, or `self` at either end. Note values keep their
|
||||
/// dot or triplet; seconds, frames and samples step by tens.
|
||||
/// Returns the time scale to which this increment belongs.
|
||||
pub fn scale(self) -> GridScale {
|
||||
match self {
|
||||
GridValue::Note { .. } => GridScale::BarsBeats,
|
||||
GridValue::Seconds(_) => GridScale::MinSecs,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) => GridScale::Timecode,
|
||||
GridValue::FeetFrames(_) => GridScale::FeetFrames,
|
||||
GridValue::Samples(_) => GridScale::Samples,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default increment for a newly selected time scale.
|
||||
pub fn for_scale(scale: GridScale) -> GridValue {
|
||||
match scale {
|
||||
GridScale::BarsBeats => GridValue::default(),
|
||||
GridScale::MinSecs => GridValue::Seconds(1.0),
|
||||
GridScale::Timecode => GridValue::Timecode(TimecodeValue::Frame),
|
||||
GridScale::FeetFrames => GridValue::FeetFrames(FeetFramesValue::Frame),
|
||||
GridScale::Samples => GridValue::Samples(100),
|
||||
GridScale::ClipsMarkers => GridValue::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next larger or smaller menu value of the same kind, or `self` at either end. Note values keep
|
||||
/// their dot or triplet modifier.
|
||||
pub fn stepped(self, larger: bool) -> GridValue {
|
||||
/// Finds the next value in a numeric increment ladder without wrapping.
|
||||
fn next<T: PartialOrd + Copy>(ladder: &[T], v: T, larger: bool) -> Option<T> {
|
||||
if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) }
|
||||
}
|
||||
@@ -98,9 +368,11 @@ impl GridValue {
|
||||
let j = if larger { i.checked_sub(1) } else { i.checked_add(1) };
|
||||
j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet })
|
||||
}
|
||||
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 1.0, 10.0], s, larger).map_or(self, GridValue::Seconds),
|
||||
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 0.5, 1.0], s, larger).map_or(self, GridValue::Seconds),
|
||||
GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames),
|
||||
GridValue::Samples(n) => next(&[1, 10, 100, 1_000, 10_000, 100_000], n, larger).map_or(self, GridValue::Samples),
|
||||
GridValue::Timecode(value) => step_enum(&TimecodeValue::ALL, value, larger).map_or(self, GridValue::Timecode),
|
||||
GridValue::FeetFrames(value) => step_enum(&FeetFramesValue::ALL, value, larger).map_or(self, GridValue::FeetFrames),
|
||||
GridValue::Samples(n) => next(&[1, 2, 10, 100, 1_000, 10_000], n, larger).map_or(self, GridValue::Samples),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +398,17 @@ impl GridValue {
|
||||
}
|
||||
GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1),
|
||||
GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1),
|
||||
GridValue::Timecode(value) => to_samples(rate.samples_per_frame(sr) * value.frames(rate)).max(1),
|
||||
GridValue::FeetFrames(value) => to_samples(rate.samples_per_frame(sr) * value.frames()).max(1),
|
||||
GridValue::Samples(n) => n.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest grid line to `pos`.
|
||||
pub fn snap(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.snap(pos, sr, rate, 0);
|
||||
}
|
||||
let lo = self.line_at_or_before(pos, sr, map, rate);
|
||||
let hi = self.next_line(lo, sr, map, rate);
|
||||
if (pos - lo) <= (hi - pos) { lo } else { hi }
|
||||
@@ -140,6 +417,7 @@ impl GridValue {
|
||||
/// The grid line at or before `pos`.
|
||||
pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
match *self {
|
||||
Self::FeetFrames(value) => value.line_at_or_before(pos, sr, rate, 0),
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
// Float ticks can land a hair either side of a line's own tick.
|
||||
let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64;
|
||||
@@ -148,12 +426,14 @@ impl GridValue {
|
||||
} else if map.tick_to_samples(t.saturating_add(1), sr) <= pos {
|
||||
t = t.saturating_add(1);
|
||||
}
|
||||
// Grid lines restart at each bar line so odd meters stay aligned.
|
||||
// Sub-bar values restart at each bar so odd meters stay aligned. A whole note is
|
||||
// always four quarter notes and therefore keeps a continuous tick origin.
|
||||
let bb = map.bar_beat_at_tick(t);
|
||||
let bar_tick = map.bar_start_tick(bb.bar);
|
||||
let step = Self::note_ticks(value, dotted, triplet, map, bar_tick);
|
||||
let k = (t - bar_tick).div_euclid(step);
|
||||
map.tick_to_samples(bar_tick + k * step, sr)
|
||||
let origin = if value == NoteValue::Whole { 0 } else { bar_tick };
|
||||
let step = Self::note_ticks(value, dotted, triplet, map, origin);
|
||||
let k = (t - origin).div_euclid(step);
|
||||
map.tick_to_samples(origin + k * step, sr)
|
||||
}
|
||||
_ => self.abs_line(self.abs_index_at_or_before(pos, sr, rate), sr, rate),
|
||||
}
|
||||
@@ -162,21 +442,60 @@ impl GridValue {
|
||||
/// Position of absolute grid line `k` (Seconds, Frames, Samples), at an exact multiple of the step
|
||||
/// rounded once, so rounding never accumulates from line to line.
|
||||
fn abs_line(&self, k: i64, sr: SampleRate, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.sample_line(i128::from(k), sr, rate, 0);
|
||||
}
|
||||
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
|
||||
return rate.samples_at_frame(Self::drop_second_frame(k, rate), sr);
|
||||
}
|
||||
if let Some(frames) = self.frame_step(rate) {
|
||||
let whole = frames.round();
|
||||
if frames == whole {
|
||||
return rate.samples_at_frame(k.saturating_mul(whole as i64), sr);
|
||||
}
|
||||
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
|
||||
return to_samples(k as f64 * step);
|
||||
}
|
||||
match *self {
|
||||
GridValue::Seconds(s) => to_samples(k as f64 * Self::seconds_step(s, sr)),
|
||||
GridValue::Frames(f) => rate.samples_at_frame(k.saturating_mul(f.clamp(1, 1_000_000)), sr),
|
||||
GridValue::Samples(n) => k.saturating_mul(n.max(1)),
|
||||
GridValue::Note { .. } => 0,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the absolute grid line at or before `pos`.
|
||||
fn abs_index_at_or_before(&self, pos: Samples, sr: SampleRate, rate: FrameRate) -> i64 {
|
||||
let mut k = match *self {
|
||||
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
|
||||
GridValue::Frames(f) => rate.frames_at(pos, sr).div_euclid(f.clamp(1, 1_000_000)),
|
||||
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
|
||||
GridValue::Note { .. } => return 0,
|
||||
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
|
||||
let tc = Timecode::from_frames(rate.frames_at(pos, sr), rate);
|
||||
let magnitude = tc.hours.saturating_mul(3600).saturating_add(tc.minutes.saturating_mul(60)).saturating_add(tc.seconds);
|
||||
let mut k = if tc.negative { magnitude.saturating_neg() } else { magnitude };
|
||||
while self.abs_line(k, sr, rate) > pos {
|
||||
let previous = k.saturating_sub(1);
|
||||
if previous == k {
|
||||
break;
|
||||
}
|
||||
k = previous;
|
||||
}
|
||||
while self.abs_line(k.saturating_add(1), sr, rate) <= pos {
|
||||
let next = k.saturating_add(1);
|
||||
if next == k {
|
||||
break;
|
||||
}
|
||||
k = next;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
let mut k = if let Some(frames) = self.frame_step(rate) {
|
||||
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
|
||||
(pos as f64 / step).floor() as i64
|
||||
} else {
|
||||
match *self {
|
||||
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
|
||||
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
|
||||
GridValue::Note { .. } => return 0,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => return 0,
|
||||
}
|
||||
};
|
||||
// Lines are rounded to whole samples, so the floored index can be one off either way.
|
||||
if self.abs_line(k, sr, rate) > pos {
|
||||
@@ -192,8 +511,36 @@ impl GridValue {
|
||||
(s.clamp(0.000_01, 3600.0) * sr.as_f64()).max(1.0)
|
||||
}
|
||||
|
||||
/// Maps a signed whole-second timecode label to its physical frame at a drop-frame rate.
|
||||
fn drop_second_frame(index: i64, rate: FrameRate) -> i64 {
|
||||
let seconds = index.unsigned_abs();
|
||||
let total_minutes = seconds / 60;
|
||||
let frames = if seconds.is_multiple_of(60) && !total_minutes.is_multiple_of(10) { if rate.nominal() >= 60 { 4 } else { 2 } } else { 0 };
|
||||
Timecode {
|
||||
negative: index < 0,
|
||||
hours: i64::try_from(seconds / 3600).unwrap_or(i64::MAX),
|
||||
minutes: i64::try_from((seconds / 60) % 60).unwrap_or(0),
|
||||
seconds: i64::try_from(seconds % 60).unwrap_or(0),
|
||||
frames,
|
||||
}
|
||||
.to_frames(rate)
|
||||
}
|
||||
|
||||
/// A frame-based increment as a possibly fractional number of frames.
|
||||
fn frame_step(&self, rate: FrameRate) -> Option<f64> {
|
||||
match *self {
|
||||
GridValue::Frames(frames) => Some(frames.clamp(1, 1_000_000) as f64),
|
||||
GridValue::Timecode(value) => Some(value.frames(rate)),
|
||||
GridValue::FeetFrames(value) => Some(value.frames()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first grid line strictly after `line`.
|
||||
pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.next_line(line, sr, rate, 0);
|
||||
}
|
||||
if !matches!(self, GridValue::Note { .. }) {
|
||||
let next = self.abs_line(self.abs_index_at_or_before(line, sr, rate).saturating_add(1), sr, rate);
|
||||
return if next > line { next } else { line.saturating_add(1) };
|
||||
@@ -205,6 +552,9 @@ impl GridValue {
|
||||
|
||||
/// All grid lines within `[start, end)`, capped at `max` lines.
|
||||
pub fn lines(&self, start: Samples, end: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate, max: usize) -> Vec<Samples> {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.lines(start, end, sr, rate, 0, max);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut p = self.line_at_or_before(start, sr, map, rate);
|
||||
while p < end && out.len() < max {
|
||||
@@ -221,10 +571,43 @@ impl GridValue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves one position through an ordered increment menu without wrapping.
|
||||
fn step_enum<T: Copy + PartialEq>(values: &[T], value: T, larger: bool) -> Option<T> {
|
||||
let index = values.iter().position(|candidate| *candidate == value)?;
|
||||
let next = if larger { index.checked_sub(1) } else { index.checked_add(1) }?;
|
||||
values.get(next).copied()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Frame origins are subtracted before rounding whole frames, quarter frames and subframes.
|
||||
#[test]
|
||||
fn feet_frames_origins_round_only_after_applying_the_frame_offset() {
|
||||
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97);
|
||||
for at in [49_650, 100_901, 177_778] {
|
||||
assert_eq!(FeetFramesValue::Foot.line_at_or_before(at, sr, rate, 1), at);
|
||||
assert_eq!(FeetFramesValue::Foot.snap(at - 1, sr, rate, 1), at);
|
||||
}
|
||||
assert_eq!(FeetFramesValue::QuarterFrame.snap(50_049, sr, rate, 1), 50_050);
|
||||
assert_eq!(FeetFramesValue::SubFrame.lines(49_665, 49_667, sr, rate, 1, 4), [49_666]);
|
||||
}
|
||||
|
||||
/// Dense subframes retain distinct sample lines and remain bounded with extreme signed origins.
|
||||
#[test]
|
||||
fn feet_frames_subframes_handle_dense_steps_and_extreme_origins() {
|
||||
let sr = SampleRate::new(8_000).unwrap();
|
||||
let rate = FrameRate::Fps120;
|
||||
for origin in [i64::MIN, -1, 0, 1, i64::MAX] {
|
||||
assert_eq!(FeetFramesValue::SubFrame.lines(-3, 4, sr, rate, origin, 20), [-3, -2, -1, 0, 1, 2, 3]);
|
||||
for at in [i64::MIN, -1, 0, 1, i64::MAX] {
|
||||
assert_eq!(FeetFramesValue::SubFrame.line_at_or_before(at, sr, rate, origin), at);
|
||||
assert_eq!(FeetFramesValue::SubFrame.snap(at, sr, rate, origin), at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_lines_are_their_own_line_at_odd_tempos() {
|
||||
let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30);
|
||||
@@ -238,6 +621,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Musical boundaries stay exact across a tempo change while absolute scales remain
|
||||
/// independent of the tempo map.
|
||||
#[test]
|
||||
fn grids_stay_aligned_across_a_tempo_change() {
|
||||
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97Drop);
|
||||
let mut map = TempoMap::default();
|
||||
map.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap();
|
||||
let musical = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: false };
|
||||
for tick in [0, TICKS_PER_QUARTER * 3, TICKS_PER_QUARTER * 4, TICKS_PER_QUARTER * 5] {
|
||||
let line = map.tick_to_samples(tick, sr);
|
||||
assert_eq!(musical.line_at_or_before(line, sr, &map, rate), line);
|
||||
}
|
||||
for absolute in [
|
||||
GridValue::Seconds(0.5),
|
||||
GridValue::Timecode(TimecodeValue::Second),
|
||||
GridValue::FeetFrames(FeetFramesValue::Foot),
|
||||
GridValue::Samples(1_000),
|
||||
] {
|
||||
let line = absolute.next_line(absolute.line_at_or_before(100_000, sr, &map, rate), sr, &map, rate);
|
||||
assert_eq!(absolute.line_at_or_before(line, sr, &map, rate), line, "{absolute:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteenth_grid_at_120() {
|
||||
let g = GridValue::default();
|
||||
@@ -248,14 +654,33 @@ mod tests {
|
||||
assert_eq!(g.lines(0, 24_000, sr, &map, rate, 100), vec![0, 6000, 12000, 18000]);
|
||||
}
|
||||
|
||||
/// Absolute increments report their expected sample lengths at a fixed rate.
|
||||
#[test]
|
||||
fn absolute_grids() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
assert_eq!(GridValue::Seconds(1.0).snap(70_000, sr, &map, rate), 48_000);
|
||||
assert_eq!(GridValue::Frames(1).step_samples(0, sr, &map, rate), 1920);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::Second).step_samples(0, sr, &map, rate), 48_000);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::FiveFrames).step_samples(0, sr, &map, rate), 9_600);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::HalfFrame).step_samples(0, sr, &map, rate), 960);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::QuarterFrame).step_samples(0, sr, &map, rate), 480);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::SubFrame).step_samples(0, sr, &map, rate), 19);
|
||||
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Foot).step_samples(0, sr, &map, rate), 30_720);
|
||||
assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1);
|
||||
}
|
||||
|
||||
/// Bar length follows the meter while a whole note always spans four quarter notes.
|
||||
#[test]
|
||||
fn a_bar_and_a_whole_note_differ_in_three_four() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::new(120.0, 3, 4).unwrap(), FrameRate::Fps25);
|
||||
let note = |value| GridValue::Note { value, dotted: false, triplet: false };
|
||||
assert_eq!(note(NoteValue::Bar).step_samples(0, sr, &map, rate), 72_000);
|
||||
assert_eq!(note(NoteValue::Whole).step_samples(0, sr, &map, rate), 96_000);
|
||||
assert_eq!(note(NoteValue::Bar).line_at_or_before(96_000, sr, &map, rate), 72_000);
|
||||
assert_eq!(note(NoteValue::Whole).line_at_or_before(96_000, sr, &map, rate), 96_000);
|
||||
assert_eq!(note(NoteValue::Half).lines(0, 150_000, sr, &map, rate, 10), [0, 48_000, 72_000, 120_000, 144_000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seconds_grid_lines_land_on_whole_seconds() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30);
|
||||
@@ -268,6 +693,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy frame grids stay aligned to exact frame starts at fractional rates.
|
||||
#[test]
|
||||
fn frame_grid_lines_land_on_frame_starts() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
@@ -291,6 +717,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Named whole- and sub-frame grids retain the exact-multiple calculation used by legacy frame grids.
|
||||
#[test]
|
||||
fn named_frame_grids_land_on_exact_subdivisions() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let map = TempoMap::default();
|
||||
let grids = TimecodeValue::ALL.into_iter().map(GridValue::Timecode).chain(FeetFramesValue::ALL.into_iter().map(GridValue::FeetFrames));
|
||||
for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] {
|
||||
for grid in grids.clone() {
|
||||
for index in 0..200 {
|
||||
let line = grid.abs_line(index, sr, rate);
|
||||
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{grid:?} {rate:?} line {index}");
|
||||
assert_eq!(grid.next_line(line, sr, &map, rate), grid.abs_line(index + 1, sr, rate), "{grid:?} {rate:?} line {index}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop-frame second grids follow labelled second boundaries in both timeline directions.
|
||||
#[test]
|
||||
fn drop_frame_second_grids_follow_timecode_labels() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let map = TempoMap::default();
|
||||
let grid = GridValue::Timecode(TimecodeValue::Second);
|
||||
for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
for seconds in [0, 59, 60, 61, 600, 601, 3600] {
|
||||
let frame = GridValue::drop_second_frame(seconds, rate);
|
||||
let line = rate.samples_at_frame(frame, sr);
|
||||
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{rate:?} second {seconds}");
|
||||
assert_eq!(grid.line_at_or_before(-line, sr, &map, rate), -line, "{rate:?} second -{seconds}");
|
||||
for offset in -2..=rate.nominal() + 2 {
|
||||
let position = rate.samples_at_frame(frame.saturating_add(offset), sr);
|
||||
let before = grid.line_at_or_before(position, sr, &map, rate);
|
||||
assert!(before <= position, "{rate:?} second {seconds}, frame offset {offset}");
|
||||
assert!(grid.next_line(before, sr, &map, rate) > position, "{rate:?} second {seconds}, frame offset {offset}");
|
||||
}
|
||||
}
|
||||
let labelled = Timecode { negative: false, hours: 0, minutes: 1, seconds: 1, frames: 0 }.to_samples(rate, sr);
|
||||
assert_eq!(grid.snap(labelled, sr, &map, rate), labelled);
|
||||
}
|
||||
|
||||
for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1800), (FrameRate::Fps59_94Drop, 3600)] {
|
||||
let boundary = rate.samples_at_frame(minute_frame, sr);
|
||||
assert_eq!(grid.line_at_or_before(boundary, sr, &map, rate), boundary, "{rate:?} positive minute boundary");
|
||||
assert_eq!(grid.snap(boundary, sr, &map, rate), boundary, "{rate:?} positive minute snap");
|
||||
assert_eq!(grid.line_at_or_before(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute boundary");
|
||||
assert_eq!(grid.snap(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute snap");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_capped() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
@@ -306,6 +781,7 @@ mod tests {
|
||||
assert_eq!(d.step_samples(0, sr, &map, rate), 36_000);
|
||||
}
|
||||
|
||||
/// Stepping follows each menu ladder and stops at its endpoints.
|
||||
#[test]
|
||||
fn values_step_to_the_next_larger_or_smaller() {
|
||||
let note = |value, triplet| GridValue::Note { value, dotted: false, triplet };
|
||||
@@ -315,9 +791,12 @@ mod tests {
|
||||
assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false));
|
||||
assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1));
|
||||
assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1));
|
||||
assert_eq!(GridValue::Seconds(10.0).stepped(true), GridValue::Seconds(10.0));
|
||||
assert_eq!(GridValue::Seconds(1.0).stepped(true), GridValue::Seconds(1.0));
|
||||
assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10));
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::Frame).stepped(false), GridValue::Timecode(TimecodeValue::HalfFrame));
|
||||
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Frame).stepped(true), GridValue::FeetFrames(FeetFramesValue::Foot));
|
||||
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
|
||||
assert_eq!(GridValue::Samples(1).stepped(true), GridValue::Samples(2));
|
||||
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ mod tempo;
|
||||
mod timecode;
|
||||
|
||||
pub use format::{TimeFormat, format_length, format_position, parse_position};
|
||||
pub use grid::{GridValue, NoteValue};
|
||||
pub use grid::{FeetFramesValue, GridScale, GridValue, NoteValue, TimecodeValue};
|
||||
pub use tempo::{BarBeat, MeterEvent, TICKS_PER_QUARTER, TempoEvent, TempoMap};
|
||||
pub use timecode::{FrameRate, Timecode};
|
||||
|
||||
|
||||
+211
-38
@@ -40,14 +40,34 @@ pub struct BarBeat {
|
||||
|
||||
/// Tempo and meter changes for a session. Always has at least one of each, at tick 0.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(from = "TempoMapData")]
|
||||
pub struct TempoMap {
|
||||
tempos: Vec<TempoEvent>,
|
||||
meters: Vec<MeterEvent>,
|
||||
/// For each tempo event, the elapsed time at its tick in quarter-note minutes (ticks / bpm,
|
||||
/// summed over the earlier segments). Lets tick/sample conversions binary-search the segment
|
||||
/// instead of walking every earlier tempo change, so converting many positions against a large
|
||||
/// tempo map stays O(log n) each. Rebuilt whenever `tempos` changes.
|
||||
#[serde(skip)]
|
||||
elapsed: Vec<f64>,
|
||||
}
|
||||
|
||||
/// The serialized form of [`TempoMap`], without the derived lookup table.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TempoMapData {
|
||||
tempos: Vec<TempoEvent>,
|
||||
meters: Vec<MeterEvent>,
|
||||
}
|
||||
|
||||
impl From<TempoMapData> for TempoMap {
|
||||
fn from(d: TempoMapData) -> Self {
|
||||
TempoMap::from_parts(d.tempos, d.meters)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TempoMap {
|
||||
fn default() -> Self {
|
||||
TempoMap { tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }], meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }] }
|
||||
TempoMap::from_parts(vec![TempoEvent { tick: 0, bpm: 120.0 }], vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +80,43 @@ fn valid_meter(numerator: u32, denominator: u32) -> bool {
|
||||
}
|
||||
|
||||
impl TempoMap {
|
||||
fn from_parts(tempos: Vec<TempoEvent>, meters: Vec<MeterEvent>) -> Self {
|
||||
let mut m = TempoMap { tempos, meters, elapsed: Vec::new() };
|
||||
m.reindex();
|
||||
m
|
||||
}
|
||||
|
||||
/// Rebuild `elapsed` from `tempos`. The first segment runs from tick 0 at the first tempo.
|
||||
fn reindex(&mut self) {
|
||||
let mut elapsed = Vec::with_capacity(self.tempos.len());
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for (i, e) in self.tempos.iter().enumerate() {
|
||||
if i > 0 {
|
||||
// Saturating: a file's ticks are indexed before `sanitize` orders them.
|
||||
acc += e.tick.saturating_sub(seg_tick) as f64 / bpm;
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
elapsed.push(acc);
|
||||
}
|
||||
self.elapsed = elapsed;
|
||||
}
|
||||
|
||||
/// Samples per quarter-note minute (the unit of `elapsed`) at `sr`.
|
||||
fn samples_per_elapsed(sr: SampleRate) -> f64 {
|
||||
sr.as_f64() * 60.0 / TICKS_PER_QUARTER as f64
|
||||
}
|
||||
|
||||
/// The tempo segment `i` (an index into `tempos`, 0 when it is empty): its start tick (0 for
|
||||
/// the first), bpm and elapsed time at its start.
|
||||
fn segment(&self, i: usize) -> (i64, f64, f64) {
|
||||
let bpm = self.tempos.get(i).or(self.tempos.first()).map_or(120.0, |e| e.bpm);
|
||||
let tick = if i == 0 { 0 } else { self.tempos.get(i).map_or(0, |e| e.tick) };
|
||||
(tick, bpm, self.elapsed.get(i).copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result<Self, TimeError> {
|
||||
let mut m = TempoMap::default();
|
||||
m.set_tempo(0, bpm)?;
|
||||
@@ -91,6 +148,7 @@ impl TempoMap {
|
||||
let (numerator, denominator) = self.meters.first().map_or((4, 4), |m| (m.numerator, m.denominator));
|
||||
self.meters.insert(0, MeterEvent { tick: 0, numerator, denominator });
|
||||
}
|
||||
self.reindex();
|
||||
}
|
||||
|
||||
/// Insert or replace the tempo at `tick`.
|
||||
@@ -105,6 +163,7 @@ impl TempoMap {
|
||||
}
|
||||
Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }),
|
||||
}
|
||||
self.reindex();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -115,6 +174,7 @@ impl TempoMap {
|
||||
}
|
||||
let before = self.tempos.len();
|
||||
self.tempos.retain(|e| e.tick != tick);
|
||||
self.reindex();
|
||||
before != self.tempos.len()
|
||||
}
|
||||
|
||||
@@ -153,18 +213,52 @@ impl TempoMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the meters after `tick`, returning each with the bar it starts.
|
||||
/// Remove the meters after `tick`, returning each with the bar it starts: the bar
|
||||
/// [`Self::bar_beat_at_tick`] gives its tick, counted in one pass over the map rather than one
|
||||
/// walk per meter, so a session with many meter changes edits in linear time.
|
||||
fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> {
|
||||
let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect();
|
||||
let mut later = Vec::new();
|
||||
let (mut bar, mut seg_tick) = (1i64, 0i64);
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
let tpb = m.ticks_per_bar();
|
||||
if m.tick > tick {
|
||||
later.push((bar.saturating_add(m.tick.saturating_sub(seg_tick) / tpb), *m));
|
||||
}
|
||||
if let Some(n) = self.meters.get(i + 1) {
|
||||
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
|
||||
seg_tick = n.tick;
|
||||
}
|
||||
}
|
||||
self.meters.retain(|e| e.tick <= tick);
|
||||
later
|
||||
}
|
||||
|
||||
/// The bar and tick of the last meter's segment, where [`Self::bar_start_tick`]'s walk ends.
|
||||
fn last_segment(&self) -> (i64, i64) {
|
||||
let (mut cur_bar, mut cur_tick) = (1i64, 0i64);
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
if let Some(n) = self.meters.get(i + 1) {
|
||||
cur_bar = cur_bar.saturating_add(ceil_div(n.tick.saturating_sub(cur_tick), m.ticks_per_bar()));
|
||||
cur_tick = n.tick;
|
||||
}
|
||||
}
|
||||
(cur_bar, cur_tick)
|
||||
}
|
||||
|
||||
/// Put meters back at the start of their bars under the current earlier meters, in bar order.
|
||||
/// Each lands at or past the last meter, so the walk to its bar resumes from there; a bar
|
||||
/// before it (meters off their bar lines) takes the full walk.
|
||||
fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) {
|
||||
let (mut cur_bar, mut cur_tick) = self.last_segment();
|
||||
for (bar, ev) in later {
|
||||
let tick = self.bar_start_tick(bar);
|
||||
let tick = if bar >= cur_bar {
|
||||
let tpb = self.meters.last().map_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }, |m| *m).ticks_per_bar();
|
||||
cur_tick.saturating_add(bar.saturating_sub(cur_bar).saturating_mul(tpb))
|
||||
} else {
|
||||
self.bar_start_tick(bar)
|
||||
};
|
||||
self.put_meter(MeterEvent { tick, ..ev });
|
||||
(cur_bar, cur_tick) = if bar >= cur_bar { (bar, tick) } else { self.last_segment() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,36 +298,20 @@ impl TempoMap {
|
||||
}
|
||||
|
||||
fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 {
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for e in self.tempos.iter().skip(1) {
|
||||
if e.tick >= tick {
|
||||
break;
|
||||
}
|
||||
acc += (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr);
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
acc + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr)
|
||||
// Tempo events after the first that start before `tick` (the segments it has passed).
|
||||
let passed = self.tempos.get(1..).map_or(0, |rest| rest.partition_point(|e| e.tick < tick));
|
||||
let (seg_tick, bpm, elapsed) = self.segment(passed);
|
||||
elapsed * Self::samples_per_elapsed(sr) + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr)
|
||||
}
|
||||
|
||||
/// Samples → ticks (fractional).
|
||||
pub fn samples_to_ticks_f(&self, s: Samples, sr: SampleRate) -> f64 {
|
||||
let s = s as f64;
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for e in self.tempos.iter().skip(1) {
|
||||
let seg = (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr);
|
||||
if acc + seg > s {
|
||||
break;
|
||||
}
|
||||
acc += seg;
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
seg_tick as f64 + (s - acc) / Self::samples_per_tick(bpm, sr)
|
||||
let scale = Self::samples_per_elapsed(sr);
|
||||
// Tempo events after the first that start at or before `s`.
|
||||
let passed = self.elapsed.get(1..).map_or(0, |rest| rest.partition_point(|e| e * scale <= s));
|
||||
let (seg_tick, bpm, elapsed) = self.segment(passed);
|
||||
seg_tick as f64 + (s - elapsed * scale) / Self::samples_per_tick(bpm, sr)
|
||||
}
|
||||
|
||||
pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 {
|
||||
@@ -319,6 +397,7 @@ impl TempoMap {
|
||||
e.bpm = valid_bpm(e.bpm * factor)?;
|
||||
}
|
||||
self.tempos = out;
|
||||
self.reindex();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -396,6 +475,36 @@ mod tests {
|
||||
assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_an_early_meter_keeps_later_bars_and_scales_linearly() {
|
||||
// 200 000 meter changes, one per bar, built directly: a walk per meter would take
|
||||
// minutes to edit here, and even to build through `set_meter`.
|
||||
let mut m = TempoMap::new(120.0, 4, 4).unwrap();
|
||||
let mut tick = 0;
|
||||
for bar in 1..200_000i64 {
|
||||
let numerator = 3 + (bar % 2) as u32;
|
||||
tick += m.meters.last().map_or(0, MeterEvent::ticks_per_bar);
|
||||
m.meters.push(MeterEvent { tick, numerator, denominator: 4 });
|
||||
}
|
||||
let sample = [1usize, 1_000, 199_998];
|
||||
let bars = |m: &TempoMap| sample.map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar);
|
||||
assert_eq!(bars(&m), [2, 1_001, 199_999]);
|
||||
m.set_meter(0, 7, 8).unwrap();
|
||||
assert_eq!(bars(&m), [2, 1_001, 199_999], "later meters keep their bar numbers");
|
||||
assert_eq!(m.meters.len(), 200_000);
|
||||
assert!(m.meters.windows(2).all(|w| w[0].tick < w[1].tick));
|
||||
assert!(m.remove_meter(m.meters[1].tick));
|
||||
assert_eq!([1usize, 999, 199_997].map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar), [3, 1_001, 199_999]);
|
||||
// Meters off their bar lines (counted as bars 2 and 4) land where they always did: on
|
||||
// bar 2 of the new meter, then two bars of their own later.
|
||||
let mut off = TempoMap::new(120.0, 4, 4).unwrap();
|
||||
off.meters.push(MeterEvent { tick: 100, numerator: 3, denominator: 4 });
|
||||
off.meters.push(MeterEvent { tick: 5_000, numerator: 5, denominator: 4 });
|
||||
off.set_meter(0, 2, 4).unwrap();
|
||||
let placed: Vec<(i64, u32)> = off.meters.iter().map(|e| (e.tick, e.numerator)).collect();
|
||||
assert_eq!(placed, vec![(0, 2), (2 * TICKS_PER_QUARTER, 3), (8 * TICKS_PER_QUARTER, 5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eighth_note_meters() {
|
||||
let m = TempoMap::new(120.0, 6, 8).unwrap();
|
||||
@@ -407,24 +516,24 @@ mod tests {
|
||||
fn sanitize_keeps_what_the_setters_accept() {
|
||||
let tempo = |tick, bpm| TempoEvent { tick, bpm };
|
||||
let meter = |tick, numerator, denominator| MeterEvent { tick, numerator, denominator };
|
||||
let mut m = TempoMap {
|
||||
tempos: vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)],
|
||||
meters: vec![meter(0, 0, 4), meter(3840, 3, 4)],
|
||||
};
|
||||
let mut m = TempoMap::from_parts(
|
||||
vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)],
|
||||
vec![meter(0, 0, 4), meter(3840, 3, 4)],
|
||||
);
|
||||
m.sanitize();
|
||||
assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]);
|
||||
assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]);
|
||||
let mut empty = TempoMap { tempos: Vec::new(), meters: Vec::new() };
|
||||
let mut empty = TempoMap::from_parts(Vec::new(), Vec::new());
|
||||
empty.sanitize();
|
||||
assert_eq!(empty, TempoMap::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_events_beyond_the_position_range() {
|
||||
let mut m = TempoMap {
|
||||
tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }],
|
||||
meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }],
|
||||
};
|
||||
let mut m = TempoMap::from_parts(
|
||||
vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }],
|
||||
vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }],
|
||||
);
|
||||
m.sanitize();
|
||||
assert_eq!(m, TempoMap::default());
|
||||
assert_eq!(m.bar_start_tick(2), TICKS_PER_QUARTER * 4);
|
||||
@@ -446,4 +555,68 @@ mod tests {
|
||||
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER * 4), BarBeat { bar: 0, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER), BarBeat { bar: 0, beat: 4, tick: 0 });
|
||||
}
|
||||
|
||||
/// The segment walk the indexed conversions replaced, kept as the reference they must match.
|
||||
fn linear_tick_to_samples(m: &TempoMap, tick: i64, sr: SampleRate) -> i64 {
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = m.tempos[0].bpm;
|
||||
for e in m.tempos.iter().skip(1) {
|
||||
if e.tick >= tick {
|
||||
break;
|
||||
}
|
||||
acc += (e.tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr);
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
to_samples(acc + (tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_conversions_match_the_segment_walk() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let mut m = TempoMap::default();
|
||||
for i in 1..200i64 {
|
||||
m.set_tempo(i * 487, 40.0 + ((i * 37) % 300) as f64).unwrap();
|
||||
}
|
||||
assert!(m.remove_tempo(487 * 50));
|
||||
for tick in (-1000..200 * 487 + 5000).step_by(113) {
|
||||
let s = m.tick_to_samples(tick, sr);
|
||||
assert_eq!(s, linear_tick_to_samples(&m, tick, sr), "tick {tick}");
|
||||
assert!((m.samples_to_ticks(s, sr) - tick).abs() <= 1, "tick {tick}");
|
||||
}
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(!json.contains("elapsed"));
|
||||
let back: TempoMap = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, m);
|
||||
assert_eq!(back.tick_to_samples(150 * 487 + 3, sr), m.tick_to_samples(150 * 487 + 3, sr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_wild_ticks_is_indexed_without_overflow() {
|
||||
let json = r#"{"tempos":[{"tick":0,"bpm":120.0},{"tick":-9223372036854775808,"bpm":100.0},{"tick":9223372036854775807,"bpm":90.0}],"meters":[{"tick":0,"numerator":4,"denominator":4}]}"#;
|
||||
let mut m: TempoMap = serde_json::from_str(json).unwrap();
|
||||
m.sanitize();
|
||||
assert_eq!(m.tempos().first().map(|e| e.tick), Some(0));
|
||||
assert_eq!(m.elapsed.len(), m.tempos().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversions_against_a_large_tempo_map_stay_fast() {
|
||||
// 200k tempo changes, then 200k conversions each way: quadratic if every conversion walks
|
||||
// the earlier segments, well under a second when it binary-searches them.
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let n = 200_000i64;
|
||||
let tempos = (0..n).map(|i| TempoEvent { tick: i * 10, bpm: 60.0 + (i % 7) as f64 }).collect();
|
||||
let mut m = TempoMap::from_parts(tempos, vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]);
|
||||
m.sanitize();
|
||||
let start = std::time::Instant::now();
|
||||
let mut sum = 0i64;
|
||||
for i in 0..n {
|
||||
let s = m.tick_to_samples(i * 10 + 5, sr);
|
||||
sum = sum.wrapping_add(s).wrapping_add(m.samples_to_ticks(s, sr));
|
||||
}
|
||||
assert!(sum != 0);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ soundcraft-video.workspace = true
|
||||
# 0.37 makes the bundled faces opt-in; the app's own font loader starts from
|
||||
# `FontDefinitions::default()`, and kittest never supplies fonts to this root.
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { workspace = true }
|
||||
web-time.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::state::MainWindow;
|
||||
use super::{
|
||||
ControlRequest, Engine, Gesture, HashMap, MeterDisplay, OutputTrouble, Player, Range, Receiver, Samples, SoundApp, TrackId, TransportRequest,
|
||||
Trouble, UiState, Value, VecDeque, bounce, dialogs, edit_window, extra_windows, fonts, import, json, menus, midi_editor, mix_window, ops_windows,
|
||||
palette, panels, recovery, sampler_window, score_editor, shortcuts, theme, updates_ui, video_track, video_window, windows,
|
||||
palette, panels, preferences, recovery, sampler_window, score_editor, shortcuts, theme, updates_ui, video_track, video_window, windows,
|
||||
};
|
||||
|
||||
mod audio;
|
||||
@@ -46,6 +46,7 @@ impl SoundApp {
|
||||
services,
|
||||
native_menu_bar: false,
|
||||
autosave_dir: None,
|
||||
fallback_take_dir: None,
|
||||
autosaver: None,
|
||||
autosaved_rev: 0,
|
||||
leftovers: Vec::new(),
|
||||
@@ -63,6 +64,7 @@ impl SoundApp {
|
||||
ops: ops_windows::OpsState::default(),
|
||||
extra: extra_windows::ExtraState::default(),
|
||||
palette: palette::PaletteState::default(),
|
||||
preferences: preferences::PreferencesState::default(),
|
||||
focus_mix: false,
|
||||
edit_scroll_into_view: None,
|
||||
mix_scroll_into_view: None,
|
||||
@@ -137,6 +139,8 @@ impl SoundApp {
|
||||
/// Each host iteration starts with `raw_input_hook`; its editor-poll admission prevents an
|
||||
/// empty queue on a discarded pass from ending a still-active plugin gesture.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
self.ui.undo_levels = self.ui.undo_levels.clamp(1, 64);
|
||||
self.engine.set_history_limit(self.ui.undo_levels);
|
||||
// Fonts set now take effect next frame, so draw only from the frame after.
|
||||
if !self.fonts_ready {
|
||||
if self.fonts_installed {
|
||||
@@ -333,6 +337,7 @@ impl SoundApp {
|
||||
}
|
||||
panels::floating(self, &ctx);
|
||||
windows::show(self, &ctx);
|
||||
preferences::show(self, &ctx);
|
||||
video_window::show(self, &ctx);
|
||||
ops_windows::show(self, &ctx);
|
||||
extra_windows::show(self, &ctx);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user