mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-04 09:20:40 -06:00
Compare commits
164
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5796bb9a | ||
|
|
b26ab2b25c | ||
|
|
76f58fcb81 | ||
|
|
6a4c055094 | ||
|
|
d5564bce05 | ||
|
|
87ad11cccc | ||
|
|
58dfbcd013 | ||
|
|
b11ffe73ae | ||
|
|
f91b4173b6 | ||
|
|
98c3a307c3 | ||
|
|
6492b5f27e | ||
|
|
9c202d6f17 | ||
|
|
44b864ffaf | ||
|
|
035de82c84 | ||
|
|
181f329ec8 | ||
|
|
5ba9f9d7b8 | ||
|
|
c15682a088 | ||
|
|
0d253b6d8c | ||
|
|
c0f2f7dc87 | ||
|
|
f99407fb1f | ||
|
|
636454e639 | ||
|
|
7546ab992b | ||
|
|
f86b077cdf | ||
|
|
aa250cc1d7 | ||
|
|
351b9d3a55 | ||
|
|
da02c19fcc | ||
|
|
64540f109b | ||
|
|
0af1ad909e | ||
|
|
7587130d59 | ||
|
|
e4f0599a2a | ||
|
|
17d3f00700 | ||
|
|
f46ea7d70c | ||
|
|
1403bbc4d3 | ||
|
|
b0793ca875 | ||
|
|
1c350101bc | ||
|
|
36d42e0136 | ||
|
|
b35dd0f9eb | ||
|
|
04dab602c7 | ||
|
|
e5d3174929 | ||
|
|
27329587c6 | ||
|
|
1dbc185e01 | ||
|
|
576ff36f61 | ||
|
|
4b06407dae | ||
|
|
1a95692f50 | ||
|
|
7dfa7e470f | ||
|
|
8f7eb4d777 | ||
|
|
ffc035ba29 | ||
|
|
8444edf075 | ||
|
|
6c3a0dfc50 | ||
|
|
d557d18118 | ||
|
|
98cda6ecaf | ||
|
|
920ec2c3f3 | ||
|
|
5e8898976e | ||
|
|
c426b889bb | ||
|
|
db51dd1739 | ||
|
|
d588e74667 | ||
|
|
bdb73895a5 | ||
|
|
1a4a88fb0d | ||
|
|
9e902b48fe | ||
|
|
265e526f6a | ||
|
|
d2298ff974 | ||
|
|
cf40296888 | ||
|
|
7169dc492e | ||
|
|
b32065a109 | ||
|
|
85ffa1f53e | ||
|
|
97e2603683 | ||
|
|
444a716396 | ||
|
|
ba86e7eae7 | ||
|
|
54d36c2fcc | ||
|
|
382965153a | ||
|
|
2de7846d4b | ||
|
|
eb095cbe81 | ||
|
|
ef2f70eb35 | ||
|
|
e60a559596 | ||
|
|
4716141401 | ||
|
|
314be8740e | ||
|
|
675673346b | ||
|
|
ba3b191440 | ||
|
|
60cd0622f2 | ||
|
|
87836c3ec2 | ||
|
|
c896668d00 | ||
|
|
deceb0cd3a | ||
|
|
6446eb5598 | ||
|
|
a4e4a44bab | ||
|
|
99895de2c7 | ||
|
|
48f61d297e | ||
|
|
6e2da18e9e | ||
|
|
13b2488e95 | ||
|
|
6ef3300aba | ||
|
|
ad3147388f | ||
|
|
a13a4f5bd2 | ||
|
|
2d88f46f02 | ||
|
|
f28a10a5dd | ||
|
|
73e3478ae3 | ||
|
|
53bb43db46 | ||
|
|
b9fee48a75 | ||
|
|
948460a953 | ||
|
|
4179cf88e6 | ||
|
|
ca6271b082 | ||
|
|
4c31f2884b | ||
|
|
1964662a71 | ||
|
|
5288e42bc9 | ||
|
|
a97ce7556c | ||
|
|
13fe1df4b8 | ||
|
|
b2a386bc7a | ||
|
|
331b1820db | ||
|
|
966e253873 | ||
|
|
e8bc4758cc | ||
|
|
af4dd7ce4c | ||
|
|
f7b0805de6 | ||
|
|
cc02045a2d | ||
|
|
ddfe991a87 | ||
|
|
8da40bef96 | ||
|
|
90e8f60bad | ||
|
|
3bd3965c51 | ||
|
|
5a1324b8bc | ||
|
|
7902d59b47 | ||
|
|
552607cbee | ||
|
|
9f5f312051 | ||
|
|
92e74f6e00 | ||
|
|
ffb95ebcf3 | ||
|
|
440999a51f | ||
|
|
4740e8d4c4 | ||
|
|
14a081304b | ||
|
|
072e548ec8 | ||
|
|
98fae00688 | ||
|
|
d2079bf09d | ||
|
|
5447c94cb6 | ||
|
|
3276358bef | ||
|
|
081f43b90a | ||
|
|
b4412929a7 | ||
|
|
ca27f0f8b4 | ||
|
|
5b69a11d89 | ||
|
|
81ab012724 | ||
|
|
a62d955a39 | ||
|
|
af4970e388 | ||
|
|
9e768d659b | ||
|
|
4e9d778734 | ||
|
|
c2f14dff49 | ||
|
|
f39413033e | ||
|
|
19f4b695e2 | ||
|
|
17d553a7a7 | ||
|
|
57ae0a8b5d | ||
|
|
d9c293a92b | ||
|
|
dcdcca4704 | ||
|
|
fd9c673694 | ||
|
|
1ffc164082 | ||
|
|
7c029c40ce | ||
|
|
afa1532aba | ||
|
|
75e5b90a86 | ||
|
|
84e591e1ca | ||
|
|
1f8b267722 | ||
|
|
dab64379cb | ||
|
|
95f25979eb | ||
|
|
929a7c5307 | ||
|
|
ee63b09cbe | ||
|
|
4f771268b0 | ||
|
|
9d5f5784d5 | ||
|
|
4071975aa4 | ||
|
|
2e057309b9 | ||
|
|
9e9177f1af | ||
|
|
3ea1f34a1d | ||
|
|
6e8c039097 | ||
|
|
5d7197a2d0 |
@@ -19,6 +19,7 @@ jobs:
|
||||
# The shared workflow creates the draft release, which needs write access to contents.
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -26,8 +26,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: soundcraft
|
||||
app-dir: SoundCraft
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft-apps folder, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
|
||||
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
|
||||
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
|
||||
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
|
||||
|
||||
## ⚠️ Assets: the absolute rule
|
||||
@@ -22,7 +20,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l <id>`), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names.
|
||||
|
||||
## Never crash
|
||||
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.**
|
||||
- No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API.
|
||||
- The same exception, same rules, for `soundcraft-vst3-host` (`crates/vst3-host`), the isolated unsafe helper crate for VST3 plugin FFI (COM vtables via the `vst3` bindings, module loading, host-side COM objects).
|
||||
@@ -67,7 +65,7 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc
|
||||
`cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit.
|
||||
|
||||
## Release
|
||||
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
|
||||
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/releasing.md`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry.
|
||||
@@ -81,11 +79,13 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
+8
-8
@@ -39,14 +39,14 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts
|
||||
|
||||
| Path | Author | Source | License |
|
||||
|---|---|---|---|
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
|
||||
| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 |
|
||||
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -3,3 +3,8 @@ Read `AGENTS.md`: it is the entry point for agents in this repo.
|
||||
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
|
||||
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
|
||||
before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+77
-26
@@ -277,6 +277,12 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -1058,17 +1064,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1091,7 +1097,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -1099,7 +1105,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1120,13 +1126,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -4005,6 +4012,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -4279,7 +4297,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -4303,7 +4321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4313,7 +4331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4325,7 +4343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
@@ -4338,7 +4356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4351,11 +4369,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"log",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4368,7 +4387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4379,7 +4398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4402,7 +4421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4412,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4428,7 +4447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4441,7 +4460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4456,7 +4475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4466,7 +4485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -4495,7 +4514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4507,7 +4526,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4523,7 +4542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4568,6 +4587,12 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -4938,6 +4963,31 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.5"
|
||||
@@ -5982,6 +6032,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -6139,7 +6190,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6359,9 +6410,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.1"
|
||||
version = "9.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4"
|
||||
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
||||
+6
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -50,14 +50,15 @@ zip = { version = "9", default-features = false }
|
||||
cpal = "0.18"
|
||||
|
||||
# Which file paths stored in a session may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
|
||||
egui = { version = "0.37" }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
|
||||
|
||||
@@ -286,3 +286,34 @@ The Font Software may be sold as part of a larger software package but no copy o
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -168,6 +168,16 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
|
||||
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
|
||||
new tracks. **Setup › Keyboard Shortcuts** lists them all.
|
||||
|
||||
In **New Tracks**, each row has its own count, format, type, Samples/Ticks timebase and name.
|
||||
Use **+** to add a row below or **−** to remove it; **Create** makes all rows in order, with one
|
||||
Undo step (up to 128 tracks per creation). The last row stays available. The dropdown widths
|
||||
fit their longest option and remain fixed when the selection changes.
|
||||
|
||||
On macOS, **⌘←/→** changes the active row's format, **⌘↑/↓** its type (also with **⌃**),
|
||||
and **⌥⌘↑/↓** its timebase. **⇧⌃⌘↓/↑** adds/removes the active row, also with **⌥**;
|
||||
**⇧⌘↓/↑** works too. Windows/Linux use **Ctrl** for **⌘**. The active row follows the
|
||||
field you click. A new row starts with its count focused and selected; Enter creates and Escape cancels.
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
|
||||
@@ -340,7 +350,15 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+6
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
|
||||
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
|
||||
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change.
|
||||
- **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
|
||||
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
|
||||
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
|
||||
@@ -156,8 +157,8 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
|
||||
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
|
||||
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
|
||||
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
|
||||
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
|
||||
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the progress docs
|
||||
set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
|
||||
localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours,
|
||||
tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session.
|
||||
- **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media
|
||||
@@ -176,6 +177,7 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
|
||||
@@ -204,6 +206,6 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added |
|
||||
| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
|
||||
| 2026-10-10 | major | Progress docs restructured: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list |
|
||||
| 2026-10-06 | minor | Alpha and parity estimates |
|
||||
|
||||
@@ -25,6 +25,7 @@ soundcraft-dsp.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
clap.workspace = true
|
||||
same-file.workspace = true
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -166,7 +166,13 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
// Control characters are written escaped: a message quoting a session file (a plugin id, a
|
||||
// path) must not start a line of its own in the log or steer the terminal.
|
||||
let mut escaped = String::with_capacity(message.len());
|
||||
for c in message.chars() {
|
||||
if c.is_control() { escaped.extend(c.escape_default()) } else { escaped.push(c) }
|
||||
}
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {escaped}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
|
||||
@@ -158,8 +158,15 @@ fn a_line_carries_time_level_thread_target_and_message() {
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "soundcraft_mix", "eq_7band: the plugin did not accept its stored state"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
// A message with breaks of its own stays one record; the record still ends in exactly one newline.
|
||||
assert!(
|
||||
format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\\nb\n"),
|
||||
"a break inside the message is escaped, the final one dropped"
|
||||
);
|
||||
assert!(
|
||||
format_line("t", log::Level::Error, "w", "x", "plugin \u{1b}[2J\r\n2026 INFO forged")
|
||||
.ends_with("x: plugin \\u{1b}[2J\\r\\n2026 INFO forged\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -185,7 +185,7 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(result) = craft_update::self_update::finish(&soundcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SoundCraft app icon (placeholder)
|
||||
|
||||
**A nightingale singing**, as an engraving-style head-and-shoulders portrait in the Crafting Apps'
|
||||
owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colour field, the bird
|
||||
owl-template framing: a full-bleed colour field, the bird
|
||||
in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off
|
||||
the bottom and right edges, three song arcs leaving the bill.
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -65,3 +62,10 @@ sync_github_name = true
|
||||
[people.luigiPinna]
|
||||
real_name = "Luigi Pinna"
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.kk376]
|
||||
real_name = "Kushagra Kumar"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -13,7 +13,7 @@ serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-vst3-host: this crate
|
||||
# An isolated unsafe helper crate, like soundcraft-vst3-host: this crate
|
||||
# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere
|
||||
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||
[lints.rust]
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! values are the Audio Unit's own (plain) values, ranges and units, so no mapping is needed.
|
||||
//! Boolean parameters become toggles, indexed ones starting at 0 become choices.
|
||||
//!
|
||||
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate (craftrules never-crash):
|
||||
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate:
|
||||
//! `unsafe` is denied crate-wide and allowed only in the private `ffi` module (hand-written
|
||||
//! AudioToolbox / CoreFoundation declarations). The public API is safe, returns
|
||||
//! `Result`/`Option` and never panics. Limits of in-process hosting: an Audio Unit that itself
|
||||
|
||||
@@ -20,7 +20,7 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
|
||||
// Whether the packet table lists each packet's size and frame count (variable per packet).
|
||||
let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false);
|
||||
while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) {
|
||||
let (body, size) = (pos + 12, size as i64);
|
||||
let body = pos + 12;
|
||||
let used = match kind {
|
||||
b"desc" if size == 32 => {
|
||||
lists_sizes = be_u32(b, body + 16) == Some(0);
|
||||
@@ -28,12 +28,12 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
|
||||
described = true;
|
||||
Some(32)
|
||||
}
|
||||
b"data" if size == -1 => Some(4),
|
||||
b"data" if size == u64::MAX => Some(4),
|
||||
b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20),
|
||||
b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?,
|
||||
b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
|
||||
b"kuki" if size > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
|
||||
b"desc" | b"pakt" | b"chan" => None,
|
||||
_ => u64::try_from(size).ok(),
|
||||
_ => Some(size),
|
||||
};
|
||||
match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) {
|
||||
Some(next) if next < b.len() => pos = next,
|
||||
@@ -85,3 +85,14 @@ fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn rejects_cookie_size_above_signed_range() {
|
||||
let mut bytes = b"caff\0\x01\0\0kuki".to_vec();
|
||||
bytes.extend_from_slice(&(1u64 << 63).to_be_bytes());
|
||||
|
||||
assert!(super::check(&bytes, 0).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,10 +511,13 @@ pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
|
||||
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
|
||||
}
|
||||
|
||||
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
|
||||
/// The vendor string is skipped unread.
|
||||
/// Whether a vendor string or comment in a (little-endian) Vorbis comment block claims more than
|
||||
/// the block holds.
|
||||
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
|
||||
let Some(vendor) = le_u32(b, body) else { return false };
|
||||
let Some(vendor) = le_u32(b, body).filter(|_| body.saturating_add(4) <= end) else { return false };
|
||||
if vendor as usize > end - (body + 4) {
|
||||
return true;
|
||||
}
|
||||
let at = (body + 4).saturating_add(vendor as usize);
|
||||
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
|
||||
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
|
||||
|
||||
@@ -24,14 +24,14 @@ pub struct Peaks {
|
||||
struct Block {
|
||||
min: f32,
|
||||
max: f32,
|
||||
squares: f32,
|
||||
squares: f64,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
|
||||
|
||||
fn point(s: f32) -> Block {
|
||||
Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } }
|
||||
Block { min: s, max: s, squares: if s.is_finite() { f64::from(s) * f64::from(s) } else { 0.0 } }
|
||||
}
|
||||
|
||||
fn of(samples: &[f32]) -> Block {
|
||||
@@ -146,7 +146,7 @@ impl Peaks {
|
||||
.map(|w| {
|
||||
w.map(|(a, b)| {
|
||||
let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len()));
|
||||
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt()
|
||||
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f64).sqrt() as f32
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -215,6 +215,13 @@ mod tests {
|
||||
assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_large_samples_keep_finite_rms() {
|
||||
let samples = vec![1e20_f32; BLOCK];
|
||||
let peaks = Peaks::build(&samples);
|
||||
assert_eq!(peaks.rms_spans(&samples, 0.0, BLOCK as f64, 0.0, 1), vec![Some(1e20)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rms_spans_match_the_samples() {
|
||||
let x = signal(20_000);
|
||||
|
||||
@@ -503,6 +503,7 @@ pub(crate) fn header(
|
||||
) -> Result<Vec<u8>> {
|
||||
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
|
||||
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
|
||||
let byte_rate = sample_rate.checked_mul(u32::from(block_align)).ok_or_else(|| AudioError::Encode("WAV byte rate overflow".into()))?;
|
||||
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
|
||||
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
|
||||
@@ -511,7 +512,7 @@ pub(crate) fn header(
|
||||
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
|
||||
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
|
||||
fmt.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&block_align.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
if extensible {
|
||||
@@ -560,3 +561,14 @@ pub(crate) fn header(
|
||||
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::header;
|
||||
|
||||
#[test]
|
||||
fn rejects_byte_rate_overflow() {
|
||||
let result = header(2, 1_100_000_000, (16, false), None, None, 0, false);
|
||||
assert!(result.is_err(), "overflowing byte rate was accepted");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])]);
|
||||
@@ -366,6 +375,7 @@ fn packets_longer_than_any_codec_uses_are_refused() {
|
||||
}
|
||||
}
|
||||
|
||||
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
|
||||
#[test]
|
||||
fn flac_metadata_fields_beyond_their_block_are_refused() {
|
||||
let with_block = |kind: u8, body: &[u8]| {
|
||||
@@ -376,7 +386,8 @@ fn flac_metadata_fields_beyond_their_block_are_refused() {
|
||||
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
|
||||
assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300);
|
||||
let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat();
|
||||
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] {
|
||||
let vendor_overflow = [&0x7fff_fff0u32.to_le_bytes()[..], &0u32.to_le_bytes()].concat();
|
||||
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment), with_block(4, &vendor_overflow)] {
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,4 +317,19 @@ mod tests {
|
||||
assert!(r.call("session.inspect", json!({})).is_ok());
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_returns_an_app_error_unchanged() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() };
|
||||
std::thread::spawn(move || {
|
||||
let Ok((mut sock, _)) = listener.accept() else { return };
|
||||
let mut line = String::new();
|
||||
let _ = BufReader::new(sock.try_clone().unwrap()).read_line(&mut line);
|
||||
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": false, "error": "transport.locate: nothing to reset"}));
|
||||
});
|
||||
let mut r = Remote::new(&addr);
|
||||
let err = r.call("engine.execute", json!({"command": "transport.locate"})).unwrap_err();
|
||||
assert_eq!(err, "transport.locate: nothing to reset");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ harness = false
|
||||
clap-sys = "0.5"
|
||||
libloading = "0.9"
|
||||
|
||||
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
|
||||
# The isolated unsafe helper crate: this crate opts out of the workspace
|
||||
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
|
||||
# FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||
[lints.rust]
|
||||
|
||||
@@ -27,6 +27,7 @@ use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_p
|
||||
use clap_sys::ext::params::{
|
||||
CLAP_EXT_PARAMS, clap_host_params, clap_param_clear_flags, clap_param_info, clap_param_rescan_flags, clap_plugin_params,
|
||||
};
|
||||
use clap_sys::ext::render::{CLAP_EXT_RENDER, CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render};
|
||||
use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state, clap_plugin_state};
|
||||
use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory};
|
||||
use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR};
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::{
|
||||
clap_host, clap_plugin_audio_ports, clap_plugin_entry, clap_plugin_factory, clap_plugin_gui, clap_plugin_latency, clap_plugin_note_ports,
|
||||
clap_plugin_params, clap_plugin_state, clap_version_is_compatible, read_cstr, scan,
|
||||
};
|
||||
use super::{CLAP_EXT_RENDER, clap_plugin_render};
|
||||
|
||||
// ---- loaded binaries -----------------------------------------------------------------------
|
||||
|
||||
@@ -42,6 +43,16 @@ fn loaded() -> &'static Mutex<HashMap<PathBuf, Arc<Bundle>>> {
|
||||
L.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// A libloading error with the system's reason. libloading's own text is only "dlopen failed"
|
||||
/// (or "dlsym failed", "LoadLibraryExW failed"); the reason (`dlerror`, the Windows error) is its
|
||||
/// source.
|
||||
fn with_reason(e: &libloading::Error) -> String {
|
||||
match std::error::Error::source(e) {
|
||||
Some(why) => format!("{e}: {why}"),
|
||||
None => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
/// Loads (once per path, then cached for the life of the process) and initialises a `.clap`.
|
||||
pub fn load(path: &Path) -> Result<Arc<Bundle>, ClapError> {
|
||||
@@ -59,12 +70,12 @@ impl Bundle {
|
||||
let exe = scan::bundle_executable(path).ok_or_else(|| err("no loadable binary".into()))?;
|
||||
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
|
||||
// hosting plugins; we only load files found in CLAP plugin folders or named explicitly.
|
||||
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(e.to_string()))?;
|
||||
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(with_reason(&e)))?;
|
||||
// SAFETY: `clap_entry` is the CLAP-mandated exported data symbol of type
|
||||
// `const clap_plugin_entry`; the symbol's address is therefore a `*const clap_plugin_entry`.
|
||||
let entry: *const clap_plugin_entry = match unsafe { lib.get::<*const clap_plugin_entry>(b"clap_entry\0") } {
|
||||
Ok(sym) => *sym,
|
||||
Err(e) => return Err(err(format!("not a CLAP plugin: {e}"))),
|
||||
Err(e) => return Err(err(format!("not a CLAP plugin (no clap_entry): {}", with_reason(&e)))),
|
||||
};
|
||||
if entry.is_null() {
|
||||
return Err(err("null clap_entry".into()));
|
||||
@@ -178,6 +189,7 @@ impl Bundle {
|
||||
latency: std::ptr::null(),
|
||||
notes: std::ptr::null(),
|
||||
state_ext: std::ptr::null(),
|
||||
render: std::ptr::null(),
|
||||
gui: Arc::new(GuiLink::default()),
|
||||
raw: Vec::with_capacity(EVENT_CAP),
|
||||
out_params: Vec::with_capacity(EVENT_CAP),
|
||||
@@ -205,6 +217,7 @@ impl Bundle {
|
||||
inst.latency = inst.extension::<clap_plugin_latency>(CLAP_EXT_LATENCY);
|
||||
inst.notes = inst.extension::<clap_plugin_note_ports>(CLAP_EXT_NOTE_PORTS);
|
||||
inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
|
||||
inst.render = inst.extension::<clap_plugin_render>(CLAP_EXT_RENDER);
|
||||
let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
|
||||
inst.gui = Arc::new(GuiLink {
|
||||
inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::{
|
||||
clap_plugin, clap_plugin_audio_ports, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_process,
|
||||
read_name,
|
||||
};
|
||||
use super::{CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render};
|
||||
|
||||
// ---- audio buffers -------------------------------------------------------------------------
|
||||
|
||||
@@ -99,6 +100,7 @@ pub(crate) struct Instance {
|
||||
pub(super) latency: *const clap_plugin_latency,
|
||||
pub(super) notes: *const clap_plugin_note_ports,
|
||||
pub(super) state_ext: *const clap_plugin_state,
|
||||
pub(super) render: *const clap_plugin_render,
|
||||
pub(super) gui: Arc<GuiLink>,
|
||||
pub(super) raw: Vec<RawEvent>,
|
||||
/// Parameter values the plugin reported through output events in the current `process`.
|
||||
@@ -158,6 +160,16 @@ impl Instance {
|
||||
unsafe { load(self.plugin, &stream) }
|
||||
}
|
||||
|
||||
/// `clap_plugin_render.set`: offline (no realtime pressure) or realtime. `false` without the
|
||||
/// extension or when the plugin can't switch.
|
||||
pub fn set_render_mode(&self, offline: bool) -> bool {
|
||||
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
|
||||
let Some(set) = (unsafe { self.render.as_ref() }).and_then(|r| r.set) else { return false };
|
||||
let mode = if offline { CLAP_RENDER_OFFLINE } else { CLAP_RENDER_REALTIME };
|
||||
// SAFETY: valid instance, called from the thread that owns it.
|
||||
unsafe { set(self.plugin, mode) }
|
||||
}
|
||||
|
||||
/// Runs `on_main_thread` if the plugin asked for a callback.
|
||||
pub fn service_callback(&mut self) {
|
||||
if self.state.callback.swap(false, Ordering::Relaxed)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! plugins exactly like the built-in ones. Plugin ids are `clap:<CLAP plugin id>`; parameter ids
|
||||
//! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`].
|
||||
//!
|
||||
//! This is SoundCraft's single isolated `unsafe` crate (craftrules never-crash): `unsafe` is
|
||||
//! This is SoundCraft's single isolated `unsafe` crate: `unsafe` is
|
||||
//! denied crate-wide and allowed only in the private `ffi` module. The public API is safe, returns
|
||||
//! `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that itself crashes
|
||||
//! (segfault) takes the process down with it; we cannot sandbox native code.
|
||||
@@ -222,10 +222,33 @@ mod tests {
|
||||
assert!(plugin_info("reverb").is_none());
|
||||
}
|
||||
|
||||
/// A new directory that this test alone created, readable only by its user on Unix. Creation
|
||||
/// fails if the path already exists: the test then loads native libraries from it, so it must
|
||||
/// never reuse a directory someone else could have put a library in. It lives next to the test
|
||||
/// binary (in the build directory), not under the temp dir: `TMPDIR`/`TEMP` can name a shared
|
||||
/// directory without a sticky bit or with inheritable write access, where another user could
|
||||
/// rename or populate it. Anyone who can write to the build directory can already replace the
|
||||
/// test binary itself, so this parent adds no new trust.
|
||||
fn fresh_private_dir(prefix: &str) -> std::path::PathBuf {
|
||||
let exe = std::env::current_exe().expect("test binary path");
|
||||
let parent = exe.parent().expect("test binary has a parent directory");
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |t| t.as_nanos());
|
||||
let d = parent.join(format!("{prefix}-{}-{nanos}", std::process::id()));
|
||||
#[cfg(unix)]
|
||||
let builder = {
|
||||
let mut b = std::fs::DirBuilder::new();
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
|
||||
b
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let builder = std::fs::DirBuilder::new();
|
||||
builder.create(&d).unwrap_or_else(|e| panic!("cannot create a fresh test directory {}: {e}", d.display()));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_files_are_errors_not_crashes() {
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let d = fresh_private_dir("soundcraft-clap-garbage");
|
||||
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
|
||||
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
|
||||
std::fs::write(d.join("empty.clap"), b"").unwrap();
|
||||
@@ -239,4 +262,17 @@ mod tests {
|
||||
assert!(instantiate("clap:does.not.exist").is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A binary the system refuses to load reports the system's reason (missing library, wrong
|
||||
/// glibc, not an ELF file), not just "dlopen failed".
|
||||
#[test]
|
||||
fn a_binary_that_does_not_load_says_why() {
|
||||
let d = fresh_private_dir("soundcraft-clap-reason");
|
||||
let f = d.join("garbage.clap");
|
||||
std::fs::write(&f, b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
|
||||
let msg = load_bundle(&f).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let reason = msg.split_once("failed: ").map_or("", |(_, r)| r.trim());
|
||||
assert!(!reason.is_empty(), "no reason in: {msg}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,11 @@ impl Plugin for ClapPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_offline(&mut self, offline: bool) {
|
||||
// `clap.render`: plugins without it render the same way either way.
|
||||
self.inst.set_render_mode(offline);
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.inst.reset();
|
||||
self.queue.retain(|e| matches!(e, Event::Param { .. }));
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
|
||||
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
|
||||
//! `process` writes 1.0 into every output channel it was given when the host's buffers match the
|
||||
//! advertised layout, -1.0 when they do not.
|
||||
//! advertised layout (2.0 once the host set the offline render mode through `clap.render`), -1.0
|
||||
//! when they do not.
|
||||
//!
|
||||
//! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a
|
||||
//! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI,
|
||||
@@ -27,6 +28,7 @@ use clap_sys::ext::latency::*;
|
||||
use clap_sys::ext::log::*;
|
||||
use clap_sys::ext::note_ports::*;
|
||||
use clap_sys::ext::params::*;
|
||||
use clap_sys::ext::render::*;
|
||||
use clap_sys::factory::plugin_factory::*;
|
||||
use clap_sys::host::clap_host;
|
||||
use clap_sys::id::clap_id;
|
||||
@@ -102,6 +104,8 @@ struct Fx {
|
||||
gui_created: bool,
|
||||
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 {
|
||||
@@ -189,8 +193,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 +204,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 +256,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 {
|
||||
@@ -543,6 +547,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 +573,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()
|
||||
}
|
||||
@@ -623,6 +640,7 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
|
||||
gui_created: false,
|
||||
gui_visible: false,
|
||||
gui_edit_pending: false,
|
||||
offline: false,
|
||||
});
|
||||
let raw = Box::into_raw(b);
|
||||
unsafe {
|
||||
|
||||
@@ -130,6 +130,19 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_renders_set_the_render_mode() {
|
||||
fixture_dir();
|
||||
for (offline, want) in [(false, 1.0), (true, 2.0)] {
|
||||
let mut p = soundcraft_clap_host::create("clap:org.soundcraft.test.wide16").unwrap();
|
||||
p.set_offline(offline);
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| x == want), "offline {offline}: {:?}", &io[0][..4]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_instances_create_and_drop_cleanly() {
|
||||
fixture_dir();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -127,8 +127,11 @@ pub fn remove_dc(ch: &mut [Vec<f32>]) {
|
||||
if c.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mean = c.iter().filter(|v| v.is_finite()).map(|&v| f64::from(v)).sum::<f64>() / c.len() as f64;
|
||||
let m = mean as f32;
|
||||
let (sum, n) = c.iter().filter(|v| v.is_finite()).fold((0.0f64, 0usize), |(s, n), &v| (s + f64::from(v), n + 1));
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let m = (sum / n as f64) as f32;
|
||||
c.iter_mut().for_each(|s| *s -= m);
|
||||
}
|
||||
}
|
||||
@@ -889,4 +892,14 @@ mod tests {
|
||||
assert_eq!(ch[0][50], 1.0);
|
||||
fade(&mut ch, 1000, 1000, FadeShape::SCurve);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_dc_ignores_non_finite_samples_in_the_mean() {
|
||||
let mut ch = vec![vec![f32::NAN, 1.0, 1.0, 1.0]];
|
||||
remove_dc(&mut ch);
|
||||
assert!(ch[0][1..].iter().all(|x| x.abs() < 1e-6), "left over dc {:?}", ch[0]);
|
||||
let mut all_bad = vec![vec![f32::NAN, f32::INFINITY]];
|
||||
remove_dc(&mut all_bad);
|
||||
assert!(all_bad[0][0].is_nan() && all_bad[0][1].is_infinite());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,11 @@ fn decode_group_audio(
|
||||
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
|
||||
for (old, rel) in audio_entries {
|
||||
let full = dir.join(rel);
|
||||
if !full.starts_with(dir) || rel.contains("..") {
|
||||
if !full.starts_with(dir)
|
||||
|| Path::new(rel)
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_)))
|
||||
{
|
||||
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
}
|
||||
let key = crate::io::file_key(&full);
|
||||
@@ -233,6 +237,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_accepts_audio_names_with_consecutive_dots_but_not_parent_dirs() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-dots-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("drums.scgrp").to_string_lossy().to_string();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap();
|
||||
let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect();
|
||||
e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap();
|
||||
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
for (i, rel) in file["audio"].as_object_mut().unwrap().values_mut().enumerate() {
|
||||
let old = dir.join(rel.as_str().unwrap());
|
||||
let new = format!("Wait... what_{i}.wav");
|
||||
std::fs::rename(&old, dir.join(&new)).unwrap();
|
||||
*rel = json!(new);
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!("../escape.wav");
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "{r:?}");
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!(dir.join("Wait... what_0.wav").to_string_lossy());
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_clips_carry_no_auto_rename_scheme() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
|
||||
|
||||
@@ -324,11 +324,15 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Opt
|
||||
Err(bad(cmd, format!("`{key}` must be samples, {{seconds}}, or a time string")))
|
||||
}
|
||||
|
||||
/// Range from `start`/`end` params, else the edit selection.
|
||||
/// Range from `start`/`end`/`length` params. A missing `start` defaults to the edit selection start;
|
||||
/// with neither `end` nor `length` either, the whole edit selection is used.
|
||||
pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result<Range> {
|
||||
let st = position_param(e, cmd, p, "start")?;
|
||||
let mut st = if p.get("start").is_some_and(Value::is_null) { None } else { position_param(e, cmd, p, "start")? };
|
||||
let en = position_param(e, cmd, p, "end")?;
|
||||
let len = position_param(e, cmd, p, "length")?;
|
||||
if st.is_none() && (en.is_some() || len.is_some()) {
|
||||
st = Some(e.session().edit.selection.start);
|
||||
}
|
||||
match (st, en, len) {
|
||||
(Some(a), Some(b), _) => Ok(Range::new(a, b)),
|
||||
(Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))),
|
||||
|
||||
@@ -14,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AudioSuite Process",
|
||||
[],
|
||||
None,
|
||||
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
|
||||
"{process: <built-in plugin id (engine.plugins)> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
|
||||
has_selection,
|
||||
process
|
||||
)]
|
||||
@@ -55,6 +55,9 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
| "speed_ramp"
|
||||
| "dc_offset_removal"
|
||||
);
|
||||
if !known && soundcraft_mix::is_third_party(&name) {
|
||||
return Err(bad("audiosuite.process", format!("`{name}` is a hosted plugin; AudioSuite runs built-in plugins only (engine.plugins)")));
|
||||
}
|
||||
if !known {
|
||||
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
|
||||
}
|
||||
@@ -137,6 +140,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
nc.name = new_name;
|
||||
nc.content = ClipContent::Audio { source: src, offset: 0 };
|
||||
nc.length = i64::try_from(frames).unwrap_or(nc.length);
|
||||
nc.clamp_fades();
|
||||
// The render already has the clip's stretch applied; keeping it would stretch the new file again.
|
||||
nc.stretch = 1.0;
|
||||
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
|
||||
@@ -227,6 +231,17 @@ mod tests {
|
||||
(crate::Engine::new(s), id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_plugin_ids_are_refused_with_the_reason() {
|
||||
let (mut e, id) = gained_clip();
|
||||
for plugin in ["clap:com.example.reverb", "vst3:00112233445566778899AABBCCDDEEFF", "au:aufx:dely:appl"] {
|
||||
let err = e.execute("audiosuite.process", &json!({"process": plugin, "clips": [id.0]})).unwrap_err().to_string();
|
||||
assert!(err.contains("built-in"), "{plugin}: {err}");
|
||||
}
|
||||
let spec = specs().into_iter().find(|s| s.id == "audiosuite.process").unwrap();
|
||||
assert!(spec.params.contains("<built-in plugin id"), "the help says which ids it takes: {}", spec.params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_and_duplicate_keep_clip_gain() {
|
||||
let (mut e, id) = gained_clip();
|
||||
@@ -246,6 +261,21 @@ mod tests {
|
||||
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortening_process_clamps_fades_to_the_new_length() {
|
||||
let (mut e, id) = gained_clip();
|
||||
{
|
||||
let c = e.session_mut().find_clip_mut(id).unwrap();
|
||||
c.fade_in.len = 60;
|
||||
c.fade_out.len = 40;
|
||||
}
|
||||
e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 0.25}, "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
assert!(c.length < 64, "{}", c.length);
|
||||
assert!(c.fade_in.len <= c.length, "fade in {} > length {}", c.fade_in.len, c.length);
|
||||
assert_eq!(c.fade_out.len, 40.min(c.length));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processed_clip_does_not_keep_the_stretch_it_baked() {
|
||||
for stretch in [0.5, 2.0] {
|
||||
|
||||
@@ -196,8 +196,14 @@ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn effects_set(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let params = p.get("params").and_then(Value::as_object).ok_or_else(|| bad("clip.effects_set", "`params` must be an object of numbers"))?;
|
||||
let vals: Vec<(String, f64)> =
|
||||
params.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))).collect();
|
||||
let vals: Vec<(String, f64)> = params
|
||||
.iter()
|
||||
.filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x)))
|
||||
.map(|(k, v)| {
|
||||
let v = if k == "gain" { v.clamp(f64::from(soundcraft_dsp::MIN_DB), f64::from(soundcraft_mix::CLIP_FX_MAX_GAIN_DB)) } else { v };
|
||||
(k, v)
|
||||
})
|
||||
.collect();
|
||||
let s = e.session_mut();
|
||||
for c in &ids {
|
||||
for (k, v) in &vals {
|
||||
@@ -316,6 +322,13 @@ mod tests {
|
||||
(Engine::new(s), t, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_set_caps_gain() {
|
||||
let (mut e, _t, ids) = engine_with(&[(0, 100, 0)]);
|
||||
e.execute("clip.effects_set", &json!({"clip": ids[0].0, "params": {"gain": 500.0}})).unwrap();
|
||||
assert_eq!(e.session().edit.values.get(&format!("clip_fx.{}.gain", ids[0].0)).copied(), Some(24.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloop_removes_copies() {
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100, 100, 0), (200, 100, 0), (300, 100, 50)]);
|
||||
|
||||
@@ -376,13 +376,7 @@ fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
if range.is_empty() && !ids.is_empty() {
|
||||
for t in &mut s.tracks {
|
||||
if let Some(pl) = t.playlist_mut() {
|
||||
let before = pl.clips.len();
|
||||
pl.clips.retain(|c| !ids.contains(&c.id));
|
||||
n += before - pl.clips.len();
|
||||
}
|
||||
}
|
||||
n = ops::remove_clips(s, &ids, sh);
|
||||
s.edit.selected_clips.clear();
|
||||
} else {
|
||||
for t in &tracks {
|
||||
|
||||
@@ -115,11 +115,13 @@ fn tempo_constant(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let r = range_param(e, id, p)?;
|
||||
let s = e.session();
|
||||
let (t0, t1) = (s.tempo.samples_to_ticks(r.start, s.sample_rate), s.tempo.samples_to_ticks(r.end, s.sample_rate));
|
||||
if t1 <= t0 {
|
||||
return tempo(e, p);
|
||||
}
|
||||
let bpm = f64_or(p, "bpm", f64::NAN);
|
||||
let s = e.session_mut();
|
||||
if t1 <= t0 {
|
||||
// An empty range sets the tempo at its start, which may come from the selection.
|
||||
s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?;
|
||||
return Ok(json!({"tick": t0, "bpm": bpm}));
|
||||
}
|
||||
// Read before the edit: the end is a musical position, and the new tempo moves it in samples.
|
||||
let after = s.tempo.tempo_at_tick(t1);
|
||||
let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1);
|
||||
@@ -427,6 +429,46 @@ mod tests {
|
||||
use crate::demo::demo_engine;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tempo_constant_with_an_empty_range_sets_the_tempo_at_its_start() {
|
||||
use soundcraft_time::Range;
|
||||
let mut e = demo_engine();
|
||||
e.session_mut().edit.selection = Range::point(96_000);
|
||||
let r = e.execute("event.tempo_constant", &json!({"bpm": 90.0, "end": 96_000})).unwrap();
|
||||
let s = e.session();
|
||||
let tick = s.tempo.samples_to_ticks(96_000, s.sample_rate);
|
||||
assert_eq!(r["tick"], tick);
|
||||
assert_eq!(s.tempo.tempo_at_tick(tick), 90.0);
|
||||
}
|
||||
|
||||
/// `start` is optional for insert/cut time: it defaults to the selection start, then `end`/`length` apply.
|
||||
#[test]
|
||||
fn insert_and_cut_time_without_start_use_selection_start() {
|
||||
use soundcraft_time::Range;
|
||||
let mut e = demo_engine();
|
||||
let a = e.session().edit.selection.start;
|
||||
let run = |e: &mut crate::Engine, sel: Range, id: &str, params: serde_json::Value| {
|
||||
e.session_mut().edit.selection = sel;
|
||||
e.execute(id, ¶ms).unwrap()
|
||||
};
|
||||
// Point selection.
|
||||
assert_eq!(run(&mut e, Range::point(a), "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
|
||||
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
|
||||
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"length": 700}))["removed"], 700);
|
||||
// A non-empty selection must not override the given `end`/`length`.
|
||||
let sel = Range::new(a, a + 100);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"end": a + 2000}))["inserted"], 2000);
|
||||
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
|
||||
// A null `start` takes the same path.
|
||||
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"start": null, "end": a + 3000}))["removed"], 3000);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": null, "length": 900}))["inserted"], 900);
|
||||
// An explicit `start` still wins over the selection.
|
||||
let r = run(&mut e, sel, "event.cut_time", json!({"start": a + 1000, "end": a + 3000}));
|
||||
assert_eq!(r["removed"], 2000);
|
||||
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": a + 1000, "length": 500}))["inserted"], 500);
|
||||
}
|
||||
|
||||
/// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position.
|
||||
#[test]
|
||||
fn tempo_change_at_position_is_applied() {
|
||||
|
||||
@@ -274,7 +274,12 @@ fn tempo_curve(e: &mut Engine, p: &Value, curve: Curve, id: &str) -> Result<Valu
|
||||
|
||||
fn tempo_stretch(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "event.tempo_stretch";
|
||||
let (r, t0, t1) = selection_ticks(e, p, id)?;
|
||||
// Here `length` is the new length of the selection, not part of the range.
|
||||
let mut range_params = p.clone();
|
||||
if let Some(m) = range_params.as_object_mut() {
|
||||
m.remove("length");
|
||||
}
|
||||
let (r, t0, t1) = selection_ticks(e, &range_params, id)?;
|
||||
let factor = match position_param(e, id, p, "length")? {
|
||||
Some(l) if l > 0 => l as f64 / r.len() as f64,
|
||||
_ => f64_or(p, "factor", f64::NAN),
|
||||
@@ -901,6 +906,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// For `event.tempo_stretch`, `length` is the new length of the selection, so the selection stays the range.
|
||||
#[test]
|
||||
fn tempo_stretch_length_keeps_selection_range() {
|
||||
let mut e = Engine::default();
|
||||
e.session_mut().edit.selection = Range::new(0, 96_000);
|
||||
e.execute("event.tempo_stretch", &json!({"length": 192_000})).unwrap();
|
||||
assert_eq!(e.session().tempo.tempo_at_tick(0), 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_curves() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -598,6 +598,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 +613,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 +654,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 +761,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,36 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
@@ -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> {
|
||||
|
||||
@@ -9,9 +9,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "transport.play", "Play", [], Some("Space"), "{from?: position}", always, |e, p| {
|
||||
if let Some(at) = position_param(e, "transport.play", p, "from")? {
|
||||
e.transport_requests.push(TransportRequest::Locate(at.max(0)));
|
||||
e.transport_requests.push(TransportRequest::PlayFrom(at.max(0)));
|
||||
} else {
|
||||
e.transport_requests.push(TransportRequest::Play);
|
||||
}
|
||||
e.transport_requests.push(TransportRequest::Play);
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(noundo "transport.stop", "Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Stop); Ok(json!({})) }),
|
||||
|
||||
@@ -96,14 +96,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
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}))
|
||||
@@ -186,6 +194,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();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before.
|
||||
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. Cancel Zoom Toggle turns it off
|
||||
//! and keeps the current view.
|
||||
use super::{CommandSpec, always, f64_or};
|
||||
use soundcraft_model::{TrackHeight, ZoomToggle};
|
||||
use soundcraft_time::MAX_POSITION;
|
||||
@@ -7,7 +8,16 @@ use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)]
|
||||
vec![
|
||||
cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle),
|
||||
cmd!(noundo "view.zoom_toggle_cancel", "Cancel Zoom Toggle", [], Some("Alt+Shift+E"), "{}", always, cancel),
|
||||
]
|
||||
}
|
||||
|
||||
/// Forget the view Zoom Toggle would go back to, leaving the zoom, scroll and track heights as they are.
|
||||
fn cancel(e: &mut Engine, _: &Value) -> Result<Value> {
|
||||
let was_on = e.session_mut().edit.zoom_toggle.take().is_some();
|
||||
Ok(json!({"on": false, "canceled": was_on}))
|
||||
}
|
||||
|
||||
fn toggle(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
@@ -73,6 +83,24 @@ mod tests {
|
||||
assert_eq!(view(&e), (500.0, 1000, vec![Medium, Fit(299), Medium]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_keeps_the_zoomed_view_and_the_next_toggle_zooms_in_again() {
|
||||
let mut e = engine(json!({"tracks": ["Audio 1", "Audio 3"], "start": 48_000, "end": 96_000}));
|
||||
e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap();
|
||||
let zoomed = view(&e);
|
||||
assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": true}));
|
||||
assert_eq!(e.session().edit.zoom_toggle, None);
|
||||
assert_eq!(view(&e), zoomed, "cancel keeps the view Zoom Toggle zoomed in to");
|
||||
assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": false}));
|
||||
assert_eq!(view(&e), zoomed);
|
||||
e.execute("view.zoom_set", &json!({"samples_per_px": 200.0})).unwrap();
|
||||
assert_eq!(e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(), json!({"on": true}));
|
||||
e.execute("view.zoom_toggle", &json!({})).unwrap();
|
||||
assert_eq!(view(&e), (200.0, 48_000, vec![Fit(249), Medium, Fit(249)]), "the toggle goes back to the view it saw after the cancel");
|
||||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "view.zoom_toggle_cancel").unwrap();
|
||||
assert_eq!(spec.shortcut, Some("Alt+Shift+E"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_and_stored_views_stay_in_range() {
|
||||
let mut e = engine(json!({"tracks": ["Audio 1"], "start": 0, "end": MAX_POSITION}));
|
||||
|
||||
@@ -177,6 +177,26 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool)
|
||||
removed
|
||||
}
|
||||
|
||||
/// Remove the clips `ids` from every track. In Shuffle the later clips on each track close the holes the movable
|
||||
/// ones leave, as a Shuffle move lifts them (see [`lift_plan`]), so a crossfade partner keeps its material. Returns
|
||||
/// how many clips went.
|
||||
pub fn remove_clips(s: &mut Session, ids: &[ClipId], shuffle: bool) -> usize {
|
||||
let ids: HashSet<ClipId> = ids.iter().copied().collect();
|
||||
let mut n = 0;
|
||||
for track in s.tracks.iter().map(|t| t.id).collect::<Vec<_>>() {
|
||||
if shuffle {
|
||||
n += lift_all(s, track, &ids).len();
|
||||
}
|
||||
// Locked clips don't lift; they go without closing up, as everything does outside Shuffle.
|
||||
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
|
||||
let before = pl.clips.len();
|
||||
pl.clips.retain(|c| !ids.contains(&c.id));
|
||||
n += before - pl.clips.len();
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Copy the material inside `range` (clips trimmed to the range), relative to `range.start`.
|
||||
pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
|
||||
let t = TimeCtx::of(s);
|
||||
@@ -799,8 +819,11 @@ pub fn trim_clip(s: &mut Session, id: ClipId, start: Option<Samples>, end: Optio
|
||||
lo = lo.max(0);
|
||||
}
|
||||
let file = |c: &Clip| {
|
||||
let start = c.start.saturating_sub(c.source_offset());
|
||||
material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(n).max(c.end())))
|
||||
// The mixer plays from `offset + rel / stretch`, so file samples span `stretch` times as many timeline samples.
|
||||
let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 };
|
||||
let scale = |n: Samples| if (stretch - 1.0).abs() >= 1e-9 { (n as f64 * stretch).round() as Samples } else { n };
|
||||
let start = c.start.saturating_sub(scale(c.source_offset()));
|
||||
material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(scale(n)).max(c.end())))
|
||||
};
|
||||
let t = TimeCtx::of(s);
|
||||
let mut c = old.clone();
|
||||
|
||||
@@ -159,6 +159,40 @@ fn clear_slip_and_shuffle() {
|
||||
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 2000, 3000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_clips_closes_each_gap_in_shuffle_only() {
|
||||
let split = |shuffle| {
|
||||
let (mut s, t) = session_with_clip();
|
||||
separate_at(&mut s, t, 2000);
|
||||
separate_at(&mut s, t, 3000);
|
||||
separate_at(&mut s, t, 4000);
|
||||
let ids: Vec<ClipId> = s.track(t).unwrap().clips().iter().map(|c| c.id).collect();
|
||||
// The first and third pieces: [1000, 2000) and [3000, 4000).
|
||||
assert_eq!(remove_clips(&mut s, &[ids[0], ids[2]], shuffle), 2);
|
||||
clips(&s, t)
|
||||
};
|
||||
assert_eq!(split(false), vec![(2000, 1000, 2000), (4000, 1000, 4000)]);
|
||||
assert_eq!(split(true), vec![(1000, 1000, 2000), (2000, 1000, 4000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_clips_in_shuffle_keeps_crossfade_partners_apart() {
|
||||
// `(start, end)` of each clip; the second one goes.
|
||||
let remove_second = |spans: &[(Samples, Samples)]| {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let ids: Vec<ClipId> = spans.iter().map(|_| s.new_clip_id()).collect();
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips = ids.iter().zip(spans).map(|(id, (a, b))| Clip::audio(*id, "c", SourceId(500), *a, *a, b - a)).collect();
|
||||
}
|
||||
assert_eq!(remove_clips(&mut s, &[ids[1]], true), 1);
|
||||
clips(&s, t).into_iter().map(|(start, len, _)| (start, start + len)).collect::<Vec<_>>()
|
||||
};
|
||||
// Crossfaded with the clip before: the hole starts where that one ends.
|
||||
assert_eq!(remove_second(&[(0, 1050), (950, 2000), (2000, 3000)]), vec![(0, 1050), (1050, 2050)]);
|
||||
// Crossfaded with the clip after: the hole ends where that one starts.
|
||||
assert_eq!(remove_second(&[(0, 1000), (1000, 2050), (1950, 3000), (4000, 5000)]), vec![(0, 1000), (1000, 2050), (3050, 4050)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_overwrites() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
@@ -364,3 +398,39 @@ fn paste_keeps_crossfade_overlap() {
|
||||
s.track(t).unwrap().clips().iter().filter(|c| c.start >= 20_000).map(|c| (c.start, c.length, c.fade_out.len, c.fade_in.len)).collect();
|
||||
assert_eq!(pasted, vec![(21_000, 2000, 500, 0), (22_500, 2500, 0, 500)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_clip_bounds_stretched_clip_to_its_file() {
|
||||
for (stretch, length, want) in [(0.5, 5000, 5000), (2.0, 1000, 20_000)] {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let id = {
|
||||
let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap();
|
||||
c.stretch = stretch;
|
||||
c.start = 0;
|
||||
c.length = length;
|
||||
if let ClipContent::Audio { offset, .. } = &mut c.content {
|
||||
*offset = 0;
|
||||
}
|
||||
c.id
|
||||
};
|
||||
let c = trim_clip(&mut s, id, None, Some(99_999), false).unwrap();
|
||||
assert_eq!(c.end(), want, "stretch {stretch}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_clip_start_bound_scales_the_offset_by_stretch() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let id = {
|
||||
let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap();
|
||||
c.stretch = 2.0;
|
||||
c.start = 10_000;
|
||||
c.length = 1000;
|
||||
if let ClipContent::Audio { offset, .. } = &mut c.content {
|
||||
*offset = 1000;
|
||||
}
|
||||
c.id
|
||||
};
|
||||
let c = trim_clip(&mut s, id, Some(0), None, false).unwrap();
|
||||
assert_eq!(c.start, 8000);
|
||||
}
|
||||
|
||||
@@ -178,6 +178,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 +215,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))?;
|
||||
@@ -734,6 +744,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)?;
|
||||
|
||||
@@ -194,6 +194,23 @@ mod bundle_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_sharing_one_payload_are_refused() {
|
||||
use std::io::Write as _;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file("Song.scraft", opts).unwrap();
|
||||
w.write_all(Session::default().to_json().unwrap().as_bytes()).unwrap();
|
||||
w.start_file("Audio Files/a.wav", opts).unwrap();
|
||||
w.write_all(&[7u8; 4096]).unwrap();
|
||||
for i in 0..100 {
|
||||
w.shallow_copy_file("Audio Files/a.wav", &format!("Audio Files/copy{i}.wav")).unwrap();
|
||||
}
|
||||
let bundle = w.finish().unwrap().into_inner();
|
||||
let err = read_bundle(&bundle, BUNDLE_LIMITS).unwrap_err();
|
||||
assert!(err.to_string().contains("share data"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bundle_is_saved_only_if_it_opens_again() {
|
||||
// #433: the writer keeps to the reader's limits.
|
||||
|
||||
@@ -2,17 +2,33 @@ use super::{ChannelFormat, Clip, ClipContent, Engine, EngineError, Result, Sampl
|
||||
|
||||
/// Most meter changes a MIDI file may bring into the tempo map; scores have a handful.
|
||||
pub(super) const MAX_MIDI_METERS: usize = 10_000;
|
||||
/// Most tempo changes a MIDI file may bring into the tempo map: conversions walk the tempo events.
|
||||
pub(super) const MAX_MIDI_TEMPOS: usize = 10_000;
|
||||
/// Most synth tracks a MIDI file may create in the session.
|
||||
pub(super) const MAX_MIDI_TRACKS: usize = 512;
|
||||
|
||||
/// Import a Standard MIDI File as MIDI tracks (one per SMF track).
|
||||
pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result<Vec<TrackId>> {
|
||||
let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let s = e.session_mut();
|
||||
if tempo_map {
|
||||
// Each meter is placed by walking the ones before it.
|
||||
if smf.meters.len() > MAX_MIDI_METERS {
|
||||
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_METERS} meter changes")));
|
||||
}
|
||||
if smf.tempos.len() > MAX_MIDI_TEMPOS {
|
||||
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TEMPOS} tempo changes")));
|
||||
}
|
||||
}
|
||||
let tracks_to_import = smf.tracks.iter().filter(|tr| !tr.sequence.notes.is_empty() || !tr.sequence.ctrls.is_empty()).count();
|
||||
if tracks_to_import > MAX_MIDI_TRACKS {
|
||||
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TRACKS} tracks")));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
if tempo_map {
|
||||
for (tick, bpm) in &smf.tempos {
|
||||
if s.tempo.tempo_at_tick(*tick) == *bpm {
|
||||
continue;
|
||||
}
|
||||
let _ = s.tempo.set_tempo(*tick, *bpm);
|
||||
}
|
||||
for (tick, n, d) in &smf.meters {
|
||||
@@ -66,15 +82,37 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
|
||||
for c in t.clips() {
|
||||
if let ClipContent::Midi { sequence } = &c.content {
|
||||
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
||||
for n in &sequence.notes {
|
||||
// Only what plays: notes starting inside the clip, cut at its end.
|
||||
let clip_end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
|
||||
for n in sequence.notes.iter().filter(|n| n.start.saturating_add(base) < clip_end) {
|
||||
let mut n = *n;
|
||||
n.start = n.start.saturating_add(base);
|
||||
n.length = n.length.min(clip_end - n.start).max(1);
|
||||
seq.notes.push(n);
|
||||
}
|
||||
for ev in &sequence.ctrls {
|
||||
let mut kept: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
|
||||
for ev in sequence.ctrls.iter().filter(|ev| ev.tick.saturating_add(base) < clip_end) {
|
||||
let mut ev = *ev;
|
||||
ev.tick = ev.tick.saturating_add(base);
|
||||
seq.ctrls.push(ev);
|
||||
kept.push(ev);
|
||||
}
|
||||
// A sustain still held at the clip end is released there.
|
||||
let mut release: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
|
||||
for ev in &kept {
|
||||
if ev.kind != soundcraft_midi::CtrlKind::Cc(64) {
|
||||
continue;
|
||||
}
|
||||
match release.iter_mut().find(|r| r.channel == ev.channel) {
|
||||
Some(r) if ev.tick >= r.tick => *r = *ev,
|
||||
Some(_) => {}
|
||||
None => release.push(*ev),
|
||||
}
|
||||
}
|
||||
seq.ctrls.append(&mut kept);
|
||||
for mut r in release.into_iter().filter(|r| r.value >= 64) {
|
||||
r.tick = clip_end;
|
||||
r.value = 0;
|
||||
seq.ctrls.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::import::add_source;
|
||||
#[cfg(test)]
|
||||
use super::midi::{MAX_MIDI_METERS, import_midi_bytes};
|
||||
use super::midi::{MAX_MIDI_METERS, MAX_MIDI_TEMPOS, MAX_MIDI_TRACKS, import_midi_bytes};
|
||||
use super::{AudioBuffer, Clip, ClipContent, Engine, EngineError, FileFormat, Range, Result, Samples, Session, TrackId, TrackKind};
|
||||
|
||||
/// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the
|
||||
@@ -196,6 +196,77 @@ mod name_tests {
|
||||
assert!(err.to_string().contains("meter changes"), "{err}");
|
||||
}
|
||||
|
||||
/// A MIDI file with more tempo changes than MAX_MIDI_TEMPOS is refused without mutating the session.
|
||||
#[test]
|
||||
fn midi_with_more_tempo_changes_than_performances_have_is_refused() {
|
||||
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
|
||||
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
|
||||
let smf = |n: usize| soundcraft_midi::Smf {
|
||||
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence: sequence.clone() }],
|
||||
tempos: (0..n as i64).map(|beat| (beat * 960, 100.0 + (beat % 40) as f64)).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut e = Engine::default();
|
||||
let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS + 1)).unwrap(), 0, true).unwrap_err();
|
||||
assert!(err.to_string().contains("tempo changes"), "{err}");
|
||||
assert_eq!((e.session().tempo.tempos().len(), e.session().tracks.len()), (1, 0), "nothing is imported");
|
||||
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS)).unwrap(), 0, true).unwrap();
|
||||
assert_eq!(e.session().tempo.tempos().len(), MAX_MIDI_TEMPOS);
|
||||
}
|
||||
|
||||
/// A MIDI file with more tracks than MAX_MIDI_TRACKS is refused without mutating the session.
|
||||
#[test]
|
||||
fn midi_with_more_tracks_than_supported_is_refused() {
|
||||
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
|
||||
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
|
||||
let smf = |n: usize| soundcraft_midi::Smf {
|
||||
tracks: (0..n).map(|i| soundcraft_midi::SmfTrack { name: format!("Tr {i}"), sequence: sequence.clone() }).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut e = Engine::default();
|
||||
let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS + 1)).unwrap(), 0, false).unwrap_err();
|
||||
assert!(err.to_string().contains("tracks"), "{err}");
|
||||
assert_eq!(e.session().tracks.len(), 0, "no tracks imported");
|
||||
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS)).unwrap(), 0, false).unwrap();
|
||||
assert_eq!(e.session().tracks.len(), MAX_MIDI_TRACKS);
|
||||
}
|
||||
|
||||
/// Consecutive equal tempo events in a MIDI file are collapsed to actual tempo changes.
|
||||
#[test]
|
||||
fn midi_collapses_equal_consecutive_tempos() {
|
||||
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
|
||||
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
|
||||
let smf = soundcraft_midi::Smf {
|
||||
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }],
|
||||
tempos: vec![(0, 120.0), (960, 120.0), (1920, 140.0), (2880, 140.0), (3840, 100.0)],
|
||||
..Default::default()
|
||||
};
|
||||
let mut e = Engine::default();
|
||||
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap();
|
||||
let tempos = e.session().tempo.tempos();
|
||||
assert_eq!(tempos.len(), 3);
|
||||
assert!((tempos[0].bpm - 120.0).abs() < 1e-3);
|
||||
assert!((tempos[1].bpm - 140.0).abs() < 1e-3);
|
||||
assert!((tempos[2].bpm - 100.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// A MIDI file replaces an existing session tempo event even if the MIDI file has consecutive equal tempos.
|
||||
#[test]
|
||||
fn midi_import_replaces_differing_existing_session_tempo() {
|
||||
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
|
||||
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
|
||||
let smf = soundcraft_midi::Smf {
|
||||
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }],
|
||||
tempos: vec![(0, 120.0), (960, 120.0)],
|
||||
..Default::default()
|
||||
};
|
||||
let mut e = Engine::default();
|
||||
e.session_mut().tempo.set_tempo(960, 80.0).unwrap();
|
||||
assert_eq!(e.session().tempo.tempo_at_tick(960), 80.0);
|
||||
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap();
|
||||
assert_eq!(e.session().tempo.tempo_at_tick(960), 120.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_sources_take_the_first_free_file_name() {
|
||||
let mut s = Session::default();
|
||||
|
||||
+109
-4
@@ -29,6 +29,12 @@ use dirty::same_document;
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use soundcraft_model as model;
|
||||
|
||||
/// Upper bound on journaled commands, so a long session cannot grow memory without limit.
|
||||
const MAX_JOURNAL_ENTRIES: usize = 10_000;
|
||||
/// Upper bound on a single journaled entry's params (serialized), so junk fields from MCP or the
|
||||
/// control channel cannot keep a huge blob alive for the life of the process.
|
||||
const MAX_JOURNAL_ENTRY_BYTES: usize = 64 * 1024; // 64 KiB
|
||||
|
||||
/// Shuts the third-party plugin hosts down, running the module exit of every plugin binary they
|
||||
/// loaded. Call it at the end of `main`, once every plugin instance (mixer, player) is gone: a
|
||||
/// plugin that has been used can crash when its module exit only runs from an exit handler.
|
||||
@@ -78,6 +84,8 @@ pub enum TransportRequest {
|
||||
/// Stop the old session's transport without applying stop-time edits to the new document.
|
||||
ResetSession,
|
||||
Play,
|
||||
/// Start playing at this position (or move there if already playing), ignoring the selection.
|
||||
PlayFrom(Samples),
|
||||
Stop,
|
||||
TogglePlay,
|
||||
Record,
|
||||
@@ -176,8 +184,9 @@ pub struct Engine {
|
||||
pub clipboard: Clipboard,
|
||||
pub transport_requests: Vec<TransportRequest>,
|
||||
pub transport: TransportStatus,
|
||||
/// Command journal (id, params) for macro recording / agents.
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Command journal (id, params) for macro recording / agents. Ring buffer: capped at
|
||||
/// [`MAX_JOURNAL_ENTRIES`] entries of up to [`MAX_JOURNAL_ENTRY_BYTES`] params each.
|
||||
pub journal: std::collections::VecDeque<(String, Value)>,
|
||||
/// Path of the session file, if saved.
|
||||
pub path: Option<String>,
|
||||
/// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).
|
||||
@@ -219,7 +228,7 @@ impl Engine {
|
||||
clipboard: Clipboard::default(),
|
||||
transport_requests: Vec::new(),
|
||||
transport: TransportStatus::default(),
|
||||
journal: Vec::new(),
|
||||
journal: std::collections::VecDeque::new(),
|
||||
path: None,
|
||||
revision: 1,
|
||||
dirty: false,
|
||||
@@ -422,7 +431,7 @@ impl Engine {
|
||||
self.push_undo(spec.label, before);
|
||||
}
|
||||
if spec.journal {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
self.journal_append(id, params);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
@@ -489,6 +498,16 @@ impl Engine {
|
||||
self.merge_key = None;
|
||||
}
|
||||
|
||||
/// Append a journaled command, clamping its params to [`MAX_JOURNAL_ENTRY_BYTES`] and the
|
||||
/// buffer to [`MAX_JOURNAL_ENTRIES`] entries (oldest are dropped).
|
||||
fn journal_append(&mut self, id: &str, params: &Value) {
|
||||
let clamped = (id.to_string(), journal_params(params));
|
||||
self.journal.push_back(clamped);
|
||||
if self.journal.len() > MAX_JOURNAL_ENTRIES {
|
||||
self.journal.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: execute with `{}` params.
|
||||
pub fn run(&mut self, id: &str) -> Result<Value> {
|
||||
self.execute(id, &json!({}))
|
||||
@@ -529,6 +548,92 @@ fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
|
||||
p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
/// The params to keep in the journal for a command: the value itself when it serializes within
|
||||
/// [`MAX_JOURNAL_ENTRY_BYTES`], or a truncation marker when it is too large or unparseable.
|
||||
///
|
||||
/// Serializes into a [`std::io::Write`] sink that fails as soon as the cap is exceeded, so an
|
||||
/// oversized `params` (a multi-MiB line from MCP or the control channel) is never fully
|
||||
/// serialized, cloned, or allocated just to be discarded.
|
||||
fn journal_params(params: &Value) -> Value {
|
||||
// Write through a bounded sink: serialization stops (WriteZero) the moment the cap is passed,
|
||||
// so an oversized value costs at most ~1× the cap, not its full serialized size.
|
||||
struct Bounded(Vec<u8>);
|
||||
impl std::io::Write for Bounded {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let room = MAX_JOURNAL_ENTRY_BYTES.saturating_sub(self.0.len());
|
||||
if room == 0 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap"));
|
||||
}
|
||||
let take = buf.len().min(room);
|
||||
self.0.extend_from_slice(&buf[..take]);
|
||||
if take < buf.len() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap"));
|
||||
}
|
||||
Ok(take)
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut sink = Bounded(Vec::new());
|
||||
// Fails with WriteZero as soon as the cap is exceeded, so the allocation stays bounded even
|
||||
// for a multi-MiB params.
|
||||
let ok = serde_json::to_writer(&mut sink, params).is_ok();
|
||||
if !ok {
|
||||
return json!({"__truncated__": true});
|
||||
}
|
||||
params.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod journal_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn journal_is_bounded_to_max_entries() {
|
||||
let mut e = Engine::default();
|
||||
// Calling the private append helper directly exercises the ring buffer without the cost
|
||||
// of cloning the whole document on 10,000+ `track.new` calls.
|
||||
for i in 0..MAX_JOURNAL_ENTRIES + 500 {
|
||||
let n = format!("t{i}");
|
||||
e.journal_append("track.new", &json!({"name": n}));
|
||||
}
|
||||
assert_eq!(e.journal.len(), MAX_JOURNAL_ENTRIES);
|
||||
// The oldest entries were dropped, the newest kept: the first remaining is the 501st pushed,
|
||||
// and the newest still there.
|
||||
let first = e.journal.front().map(|(_, p)| p["name"].as_str().unwrap_or_default());
|
||||
assert_eq!(first, Some("t500"));
|
||||
let last = e.journal.back().map(|(_, p)| p["name"].as_str().unwrap_or_default());
|
||||
let last_name = format!("t{}", MAX_JOURNAL_ENTRIES + 499);
|
||||
assert_eq!(last, Some(last_name.as_str()));
|
||||
assert_eq!(e.journal.back().map(|(id, _)| id.as_str()), Some("track.new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_params_are_truncated_in_the_journal() {
|
||||
let big = "x".repeat(MAX_JOURNAL_ENTRY_BYTES * 4);
|
||||
assert_eq!(journal_params(&json!({"name": big})), json!({"__truncated__": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_params_are_kept_whole() {
|
||||
let p = json!({"track": "Kick", "db": -6.0});
|
||||
assert_eq!(journal_params(&p), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_boundary_is_exact() {
|
||||
// A value just under the cap is kept whole; one just over is truncated. "Just" means the
|
||||
// bounded writer made at most ~two allocations of cap size, not a full multi-MiB buffer.
|
||||
// `{"n":"…"}` adds 9 bytes around the string.
|
||||
let small_str = MAX_JOURNAL_ENTRY_BYTES - 64;
|
||||
let small = json!({"n": "x".repeat(small_str)});
|
||||
assert_eq!(journal_params(&small), small);
|
||||
let big = json!({"n": "x".repeat(MAX_JOURNAL_ENTRY_BYTES)});
|
||||
assert_eq!(journal_params(&big), json!({"__truncated__": true}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -644,6 +652,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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -299,6 +299,22 @@ fn shuffle_clear_moves_automation_with_the_clips() {
|
||||
assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]);
|
||||
}
|
||||
|
||||
/// Clearing selected clips by id closes their gaps in Shuffle, as clearing their ranges does, and moves the
|
||||
/// automation with the clips.
|
||||
#[test]
|
||||
fn shuffle_clear_by_clip_id_closes_each_gap() {
|
||||
for (mode, want, want_points) in [
|
||||
("shuffle", [(0, 10_000), (20_000, 10_000)], [0, 9_000, 20_000, 29_000]),
|
||||
("slip", [(0, 10_000), (40_000, 10_000)], [0, 9_000, 40_000, 49_000]),
|
||||
] {
|
||||
let (mut e, t, ids) = long(mode, &[(0, 10_000, 0), (20_000, 10_000, 0), (30_000, 10_000, 0), (40_000, 10_000, 0)]);
|
||||
set_points(&mut e, t, &[0, 9_000, 40_000, 49_000]);
|
||||
run(&mut e, "edit.clear", json!({"clips": [ids[1].0, ids[2].0], "tracks": [t.0], "start": 0}));
|
||||
assert_eq!(clips(&e, t), want, "{mode}");
|
||||
assert_eq!(points(&e, t), want_points, "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() {
|
||||
let (mut e, t, [a, _]) = engine("relative_grid");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ pub(crate) fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
|
||||
&& let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0)))
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
return l.value_at(pos, base);
|
||||
return l.value_at(pos, base).clamp(soundcraft_dsp::MIN_DB, crate::SEND_LEVEL_MAX_DB);
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ impl ClipFx {
|
||||
c.set_param(p, v);
|
||||
}
|
||||
} else if k == "gain" {
|
||||
self.gain_db = v;
|
||||
self.gain_db = if v.is_nan() { 0.0 } else { v.clamp(soundcraft_dsp::MIN_DB, crate::CLIP_FX_MAX_GAIN_DB) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+15
-1
@@ -89,6 +89,11 @@ use strip::{DelayLine, MAX_LATENCY};
|
||||
use threads::run_strips;
|
||||
pub use threads::{mark_audio_thread, on_audio_thread};
|
||||
|
||||
/// Top of the clip effects gain slider; a stored or wired gain is clamped to it (#884).
|
||||
pub const CLIP_FX_MAX_GAIN_DB: f32 = 24.0;
|
||||
/// Top of a send fader, the same +12 dB `AutoParam::SendLevel` ranges up to (#884).
|
||||
pub const SEND_LEVEL_MAX_DB: f32 = 12.0;
|
||||
|
||||
/// Default (stereo) main mix width. The actual width is [`main_channels`].
|
||||
pub const MAIN_CHANNELS: usize = 2;
|
||||
|
||||
@@ -139,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`]).
|
||||
@@ -172,6 +179,7 @@ impl MixEngine {
|
||||
recording: false,
|
||||
metronome: false,
|
||||
external: false,
|
||||
offline: false,
|
||||
adopt: HashMap::new(),
|
||||
retired: Vec::new(),
|
||||
work: Vec::new(),
|
||||
@@ -184,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>) {
|
||||
@@ -266,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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+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<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
+128
-8
@@ -1,6 +1,6 @@
|
||||
use super::automation::{GainRamp, transport_at, volume_db_at};
|
||||
use super::clips::{ClipFx, clip_fx_params, render_clip};
|
||||
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance, is_third_party};
|
||||
use super::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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,21 +496,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 +541,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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,15 @@ fn sends_feed_aux_via_bus() {
|
||||
assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_level_automation_is_capped_at_the_fader_top() {
|
||||
// #884: a send lane point past +12 dB (pushed without `set_point`'s clamp) reads as +12.
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.lane_mut(&AutoParam::SendLevel(0)).points.push(soundcraft_model::AutomationPoint { at: 0, value: 500.0 });
|
||||
assert_eq!(send_level(s.track(t).unwrap(), 0, 0.0, 100), SEND_LEVEL_MAX_DB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bus_is_the_bus_not_the_main_mix() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
|
||||
@@ -80,7 +80,8 @@ impl AutoParam {
|
||||
}
|
||||
/// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`.
|
||||
pub fn parse(s: &str) -> Option<AutoParam> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
let orig = s.trim();
|
||||
let s = orig.to_ascii_lowercase();
|
||||
match s.as_str() {
|
||||
"volume" | "vol" => return Some(AutoParam::Volume),
|
||||
"pan" | "pan1" => return Some(AutoParam::Pan(0)),
|
||||
@@ -100,6 +101,8 @@ impl AutoParam {
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("plugin:") {
|
||||
let (slot, param) = rest.split_once(':')?;
|
||||
// ASCII lowercasing keeps byte offsets, so take the param from the original.
|
||||
let param = orig.get(orig.len() - param.len()..)?;
|
||||
return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() });
|
||||
}
|
||||
None
|
||||
@@ -158,7 +161,10 @@ impl AutomationLane {
|
||||
if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) {
|
||||
return a.value;
|
||||
}
|
||||
a.value + (b.value - a.value) * ((at - a.at) as f32 / (b.at - a.at) as f32)
|
||||
// Widen so far-apart breakpoints cannot overflow i64.
|
||||
let span = i128::from(b.at) - i128::from(a.at);
|
||||
let off = i128::from(at) - i128::from(a.at);
|
||||
a.value + (b.value - a.value) * (off as f64 / span as f64) as f32
|
||||
}
|
||||
(Some(a), _) => a.value,
|
||||
_ => default,
|
||||
@@ -255,6 +261,26 @@ impl AutomationLane {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_keeps_plugin_param_case() {
|
||||
let p = AutoParam::Plugin { slot: 0, param: "Cutoff".into() };
|
||||
assert_eq!(AutoParam::parse("plugin:a:Cutoff"), Some(p));
|
||||
assert_eq!(AutoParam::parse("Plugin:B:Mix"), Some(AutoParam::Plugin { slot: 1, param: "Mix".into() }));
|
||||
assert_eq!(AutoParam::parse("Volume"), Some(AutoParam::Volume));
|
||||
assert_eq!(AutoParam::parse("SEND_B_LEVEL"), Some(AutoParam::SendLevel(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_at_far_apart_points_does_not_overflow() {
|
||||
let mut l = AutomationLane::new(AutoParam::Volume);
|
||||
l.set_point(-1, 0.0);
|
||||
l.set_point(i64::MAX, -10.0);
|
||||
let v = l.value_at(5, 0.0);
|
||||
assert!(v.is_finite() && (-10.0..=0.0).contains(&v));
|
||||
let w = l.value_at(i64::MAX - 1, 0.0);
|
||||
assert!(w.is_finite() && (-10.0..=0.0).contains(&w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_label_max_does_not_overflow() {
|
||||
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
|
||||
|
||||
@@ -626,6 +626,8 @@ impl Session {
|
||||
}
|
||||
for l in &mut t.automation {
|
||||
l.points.retain(|p| p.value.is_finite());
|
||||
let (lo, hi, _) = l.param.range();
|
||||
l.points.iter_mut().for_each(|p| p.value = p.value.clamp(lo, hi));
|
||||
l.points.iter_mut().for_each(|p| bound(&mut p.at));
|
||||
l.points.sort_by_key(|p| p.at);
|
||||
}
|
||||
@@ -905,6 +907,22 @@ mod tests {
|
||||
assert!(s.next_id > t.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_automation_is_clamped_to_its_range() {
|
||||
// #884: a session file can carry points outside a lane's range; load clamps them.
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] {
|
||||
tr.lane_mut(&p).points.push(crate::AutomationPoint { at: 0, value: 500.0 });
|
||||
}
|
||||
let s = Session::from_json(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
let tr = s.track(t).unwrap();
|
||||
for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] {
|
||||
assert_eq!(tr.lane(&p).unwrap().points[0].value, 12.0, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_auto_name_scheme_leaves_room_for_its_number() {
|
||||
let mut scheme = crate::AutoName { name: "n".repeat(300), suffix: "s".repeat(300), ..Default::default() };
|
||||
|
||||
@@ -120,6 +120,12 @@ pub fn parse_position(
|
||||
if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) {
|
||||
return Err(err());
|
||||
}
|
||||
if rate.is_drop() && tc.seconds == 0 && tc.minutes % 10 != 0 {
|
||||
let dropped = if rate.nominal() >= 60 { 4 } else { 2 };
|
||||
if tc.frames < dropped {
|
||||
return Err(err());
|
||||
}
|
||||
}
|
||||
Ok(tc.to_samples(rate, sr).saturating_sub(tc_start))
|
||||
}
|
||||
TimeFormat::FeetFrames => {
|
||||
@@ -213,6 +219,20 @@ mod tests {
|
||||
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_skipped_labels_are_rejected() {
|
||||
let (sr, map, _) = ctx();
|
||||
for (rate, skipped) in [
|
||||
(FrameRate::Fps29_97Drop, &["00:01:00;00", "00:01:00;01"][..]),
|
||||
(FrameRate::Fps59_94Drop, &["00:01:00;00", "00:01:00;01", "00:01:00;02", "00:01:00;03"][..]),
|
||||
] {
|
||||
for text in skipped {
|
||||
assert!(parse_position(text, TimeFormat::Timecode, sr, &map, rate, 0, 0).is_err(), "accepted skipped label {text} at {rate:?}");
|
||||
}
|
||||
assert!(parse_position("00:10:00;00", TimeFormat::Timecode, sr, &map, rate, 0, 0).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_lengths() {
|
||||
let (sr, map, rate) = ctx();
|
||||
|
||||
@@ -122,7 +122,7 @@ impl GridValue {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
let t0 = map.samples_to_ticks(at, sr);
|
||||
let dt = Self::note_ticks(value, dotted, triplet, map, t0);
|
||||
(map.tick_to_samples(t0 + dt, sr) - map.tick_to_samples(t0, sr)).max(1)
|
||||
(map.tick_to_samples(t0.saturating_add(dt), sr).saturating_sub(map.tick_to_samples(t0, sr))).max(1)
|
||||
}
|
||||
GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1),
|
||||
GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1),
|
||||
@@ -320,4 +320,11 @@ mod tests {
|
||||
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
|
||||
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_at_extreme_position_does_not_overflow() {
|
||||
let sr = SampleRate::new(8000).unwrap();
|
||||
let map = TempoMap::new(1000.0, 4, 4).unwrap();
|
||||
let _ = GridValue::default().snap(i64::MAX, sr, &map, FrameRate::Fps30);
|
||||
}
|
||||
}
|
||||
|
||||
+163
-39
@@ -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()
|
||||
}
|
||||
|
||||
@@ -204,36 +264,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 {
|
||||
@@ -251,10 +295,10 @@ impl TempoMap {
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
let next_tick = self.meters.get(i + 1).map(|n| n.tick);
|
||||
let tpb = m.ticks_per_bar();
|
||||
let bars_in_seg = next_tick.map(|nt| ((nt - cur_tick) + tpb - 1) / tpb);
|
||||
let bars_in_seg = next_tick.map(|nt| ceil_div(nt.saturating_sub(cur_tick), tpb));
|
||||
match bars_in_seg {
|
||||
Some(n) if bar >= cur_bar + n => {
|
||||
cur_bar += n;
|
||||
Some(n) if bar >= cur_bar.saturating_add(n) => {
|
||||
cur_bar = cur_bar.saturating_add(n);
|
||||
cur_tick = next_tick.unwrap_or(cur_tick);
|
||||
}
|
||||
_ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)),
|
||||
@@ -284,7 +328,7 @@ impl TempoMap {
|
||||
if let Some(n) = self.meters.get(i + 1)
|
||||
&& tick >= n.tick
|
||||
{
|
||||
bar += ((n.tick - seg_tick) + tpb - 1) / tpb;
|
||||
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
|
||||
seg_tick = n.tick;
|
||||
continue;
|
||||
}
|
||||
@@ -319,10 +363,16 @@ impl TempoMap {
|
||||
e.bpm = valid_bpm(e.bpm * factor)?;
|
||||
}
|
||||
self.tempos = out;
|
||||
self.reindex();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Ceiling division for a non-negative `num` and positive `den`, without overflow.
|
||||
fn ceil_div(num: i64, den: i64) -> i64 {
|
||||
num / den + i64::from(num % den != 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -337,6 +387,16 @@ mod tests {
|
||||
assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_near_max_tick_does_not_overflow() {
|
||||
let mut m = TempoMap::default();
|
||||
m.set_meter(i64::MAX, 7, 8).unwrap();
|
||||
let bb = m.bar_beat_at_tick(i64::MAX);
|
||||
assert!(bb.bar > 1);
|
||||
assert!(m.bar_start_tick(bb.bar) > 0);
|
||||
assert!(m.bar_start_tick(i64::MAX) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_bar_saturates_instead_of_overflowing() {
|
||||
let m = TempoMap::default();
|
||||
@@ -392,24 +452,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);
|
||||
@@ -431,4 +491,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
||||
//! standards/contributors.md). Nothing is read at run time.
|
||||
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
|
||||
//!
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
|
||||
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml
|
||||
//! entry is verified.
|
||||
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
||||
//! JSON says the entry was verified (`people_entry`).
|
||||
//!
|
||||
|
||||
@@ -105,6 +105,7 @@ impl SoundApp {
|
||||
quit_requested: false,
|
||||
arrange_request: false,
|
||||
touched: std::collections::HashSet::new(),
|
||||
touched_sends: std::collections::HashSet::new(),
|
||||
last_write_at: i64::MIN,
|
||||
editor_states: Vec::new(),
|
||||
editor_poll_due: true,
|
||||
@@ -159,7 +160,7 @@ impl SoundApp {
|
||||
if hidden {
|
||||
self.pointer_down = false;
|
||||
self.wants_keyboard = false;
|
||||
if matches!(self.gesture, Some(Gesture::Fader { .. })) {
|
||||
if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) {
|
||||
self.gesture = None;
|
||||
}
|
||||
}
|
||||
@@ -179,11 +180,11 @@ impl SoundApp {
|
||||
updates_ui::tick(self, ctx);
|
||||
}
|
||||
if !self.pointer_down {
|
||||
if matches!(self.gesture, Some(Gesture::Fader { .. })) {
|
||||
if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) {
|
||||
self.gesture = None;
|
||||
}
|
||||
// Keep a running automation pass merged until playback stops.
|
||||
if !self.wants_keyboard && self.touched.is_empty() {
|
||||
if !self.wants_keyboard && self.touched.is_empty() && self.touched_sends.is_empty() {
|
||||
self.engine.end_merge();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ impl SoundApp {
|
||||
// Re-selecting a scrolling mode resumes the playhead follow.
|
||||
self.edit_layout.follow_hold = false;
|
||||
}
|
||||
if id == "view.zoom_toggle_cancel" && v["canceled"] == true {
|
||||
self.edit_layout.forget_zoom_back();
|
||||
}
|
||||
if id == "track.clear_clip_indicators" {
|
||||
self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false);
|
||||
}
|
||||
|
||||
@@ -204,7 +204,8 @@ impl SoundApp {
|
||||
}
|
||||
|
||||
/// While playing, record volume automation from fader positions for tracks in Write mode, and
|
||||
/// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Each point replaces the
|
||||
/// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Send levels follow the
|
||||
/// same rules, per send, from its slot or its floating send window. Each point replaces the
|
||||
/// automation since the previous one, and Stop writes a last point. A pass and whatever is
|
||||
/// dragged during it, its fader included, are one undo step.
|
||||
pub(crate) fn write_automation(&mut self) {
|
||||
@@ -214,7 +215,7 @@ impl SoundApp {
|
||||
let may_write =
|
||||
playing && self.engine.session().tracks.iter().any(|t| matches!(t.mixer.automation_mode, M::Write | M::Touch | M::Latch | M::TouchLatch));
|
||||
self.engine.hold_merge(may_write.then_some(PASS));
|
||||
if !playing && self.touched.is_empty() {
|
||||
if !playing && self.touched.is_empty() && self.touched_sends.is_empty() {
|
||||
self.last_write_at = i64::MIN;
|
||||
return;
|
||||
}
|
||||
@@ -230,6 +231,10 @@ impl SoundApp {
|
||||
Some(Gesture::Fader { track, .. }) => Some(*track),
|
||||
_ => None,
|
||||
};
|
||||
let send_fader = match &self.gesture {
|
||||
Some(Gesture::SendFader { track, slot }) => Some((*track, *slot)),
|
||||
_ => None,
|
||||
};
|
||||
let writes: Vec<(TrackId, f32)> = self
|
||||
.engine
|
||||
.session()
|
||||
@@ -247,9 +252,39 @@ impl SoundApp {
|
||||
write.then_some((t.id, t.mixer.volume_db))
|
||||
})
|
||||
.collect();
|
||||
for (t, v) in writes {
|
||||
let send_writes: Vec<(TrackId, usize, f32)> = self
|
||||
.engine
|
||||
.session()
|
||||
.tracks
|
||||
.iter()
|
||||
.flat_map(|t| {
|
||||
let m = t.mixer.automation_mode;
|
||||
t.mixer.sends.iter().enumerate().filter_map(move |(slot, s)| s.as_ref().map(|s| (t.id, m, slot, s.level_db)))
|
||||
})
|
||||
.filter(|&(t, m, slot, _)| {
|
||||
let touched_now = dragging && send_fader == Some((t, slot));
|
||||
match m {
|
||||
M::Write => true,
|
||||
M::Touch => touched_now,
|
||||
M::Latch | M::TouchLatch => touched_now || self.touched_sends.contains(&(t, slot)),
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.map(|(t, _, slot, db)| (t, slot, db))
|
||||
.collect();
|
||||
let points: Vec<(TrackId, String, f32)> = writes
|
||||
.iter()
|
||||
.map(|&(t, v)| (t, "volume".to_string(), v))
|
||||
.chain(send_writes.iter().map(|&(t, slot, v)| (t, format!("send_{}_level", (b'a' + slot as u8) as char), v)))
|
||||
.collect();
|
||||
for &(t, _) in &writes {
|
||||
self.touched.insert(t);
|
||||
let mut point = serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v});
|
||||
}
|
||||
for &(t, slot, _) in &send_writes {
|
||||
self.touched_sends.insert((t, slot));
|
||||
}
|
||||
for (t, param, v) in points {
|
||||
let mut point = serde_json::json!({"track": t.0, "param": param, "at": pos, "value": v});
|
||||
if from != i64::MIN {
|
||||
point["from"] = from.into();
|
||||
}
|
||||
@@ -257,6 +292,7 @@ impl SoundApp {
|
||||
}
|
||||
if !playing {
|
||||
self.touched.clear();
|
||||
self.touched_sends.clear();
|
||||
self.engine.end_merge();
|
||||
}
|
||||
}
|
||||
@@ -296,6 +332,7 @@ impl SoundApp {
|
||||
self.punch = None;
|
||||
self.gesture = None;
|
||||
self.touched.clear();
|
||||
self.touched_sends.clear();
|
||||
self.last_write_at = i64::MIN;
|
||||
self.edit_layout.follow_hold = false;
|
||||
self.meters.clear();
|
||||
@@ -306,6 +343,21 @@ impl SoundApp {
|
||||
self.play_from_selection();
|
||||
}
|
||||
}
|
||||
TransportRequest::PlayFrom(at) => {
|
||||
if self.is_playing() {
|
||||
// Playing on from `at` drops the end and loop of the earlier selection.
|
||||
if let Some(p) = &self.player {
|
||||
p.play(at, None, None);
|
||||
}
|
||||
if let Some(sim) = &mut self.sim {
|
||||
*sim = (at, None, None);
|
||||
}
|
||||
self.last_write_at = i64::MIN;
|
||||
self.engine.transport.position = at;
|
||||
} else {
|
||||
self.start_play(at, None, None);
|
||||
}
|
||||
}
|
||||
TransportRequest::Stop | TransportRequest::Pause => self.stop_play(),
|
||||
TransportRequest::TogglePlay => {
|
||||
if self.is_playing() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The About window's credits: every contributor (GitHub login always; display and real names only
|
||||
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
|
||||
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
|
||||
//! build.rs from contributors/contributors.json; nothing is
|
||||
//! read at run time.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
+259
-67
@@ -23,6 +23,7 @@ use files::{OPEN_PENDING, default_dir, open_pending_session, path_dialog};
|
||||
use strip_silence::{AUTO_NAME_LABELS, STRIP_NAME_LABELS, StripRectangles, auto_rename_action, strip_name_fields, strip_silence_action};
|
||||
pub use strip_silence::{StripRects, strip_silence_rectangles};
|
||||
|
||||
pub mod new_tracks;
|
||||
pub mod ops_windows;
|
||||
pub(super) mod recent;
|
||||
pub mod recovery;
|
||||
@@ -30,7 +31,7 @@ pub mod spot;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Dialog {
|
||||
NewTracks { count: u32, format: String, kind: String, timebase: String, name: String },
|
||||
NewTracks(new_tracks::NewTracks),
|
||||
NewSession { name: String, sample_rate: u32, demo: bool },
|
||||
Bounce { path: String, format: FileFormat, bit_depth: String, normalize: bool, dither: bool },
|
||||
RenameTrack { id: TrackId, name: String },
|
||||
@@ -82,9 +83,10 @@ pub struct Dialogs {
|
||||
}
|
||||
|
||||
impl Dialogs {
|
||||
/// Returns the stable UI identifier of the currently open dialog.
|
||||
pub fn open_name(&self) -> Option<&'static str> {
|
||||
self.open.as_ref().map(|d| match d {
|
||||
Dialog::NewTracks { .. } => "new_tracks",
|
||||
Dialog::NewTracks(_) => "new_tracks",
|
||||
Dialog::NewSession { .. } => "new_session",
|
||||
Dialog::Bounce { .. } => "bounce",
|
||||
Dialog::RenameTrack { .. } => "rename_track",
|
||||
@@ -166,9 +168,7 @@ impl Dialogs {
|
||||
let ed = &e.session().edit;
|
||||
return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone());
|
||||
}
|
||||
"track.new" => {
|
||||
Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
|
||||
}
|
||||
"track.new" => Dialog::NewTracks(new_tracks::NewTracks::default()),
|
||||
"session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false },
|
||||
"file.bounce_mix" => {
|
||||
let dir = e
|
||||
@@ -281,54 +281,6 @@ fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option<
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// The New Tracks form; the command to run once Create is pressed.
|
||||
fn new_tracks_form(
|
||||
ui: &mut egui::Ui,
|
||||
count: &mut u32,
|
||||
format: &mut String,
|
||||
kind: &mut String,
|
||||
timebase: &mut String,
|
||||
name: &mut String,
|
||||
enter: bool,
|
||||
) -> Option<(String, Value)> {
|
||||
ui.horizontal(|ui| {
|
||||
let create = ui.label("Create");
|
||||
ui.add(egui::DragValue::new(count).range(1..=128)).labelled_by(create.id);
|
||||
ui.label("new");
|
||||
let fmt = craft_ui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| {
|
||||
for f in soundcraft_model::ChannelFormat::ALL {
|
||||
ui.selectable_value(format, f.label().to_string(), f.label());
|
||||
}
|
||||
});
|
||||
fmt.response.accessible_name("Channel format");
|
||||
let kind_combo = craft_ui::ComboBox::from_id_salt("nt_kind")
|
||||
.selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label()))
|
||||
.show_ui(ui, |ui| {
|
||||
for k in soundcraft_model::TrackKind::ALL {
|
||||
if ui.selectable_label(kind == k.id(), k.label()).clicked() {
|
||||
*kind = k.id().to_string();
|
||||
*name = k.default_name().to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
kind_combo.response.accessible_name("Track kind");
|
||||
ui.label("in");
|
||||
let tb = craft_ui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui(ui, |ui| {
|
||||
ui.selectable_value(timebase, "samples".to_string(), "Samples");
|
||||
ui.selectable_value(timebase, "ticks".to_string(), "Ticks");
|
||||
});
|
||||
tb.response.accessible_name("Timebase");
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name:");
|
||||
crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name");
|
||||
});
|
||||
if buttons(ui, "Create", enter) {
|
||||
return Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name})));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The Bounce form's file row: a path field and a Browse button that also picks the format.
|
||||
fn bounce_file_row(app: &mut SoundApp, ui: &mut egui::Ui, path: &mut String, format: &mut FileFormat) {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -462,6 +414,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialogs.open.take() else { return };
|
||||
let mut keep = true;
|
||||
let mut action: Option<(String, Value)> = None;
|
||||
let mut bounce_params: Option<Value> = None;
|
||||
let mut save_first: Option<(String, Value)> = None;
|
||||
let confirmed = matches!(d, Dialog::SaveChanges { .. });
|
||||
// Strip Silence's Rename button, and the naming scheme its dialog's OK keeps.
|
||||
@@ -469,7 +422,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let mut renamed: Option<StripNames> = None;
|
||||
let strip_names = app.dialogs.strip_names.clone();
|
||||
let title = match &d {
|
||||
Dialog::NewTracks { .. } => "New Tracks",
|
||||
Dialog::NewTracks(_) => "New Tracks",
|
||||
Dialog::NewSession { .. } => "New Session",
|
||||
Dialog::Bounce { .. } => "Bounce Mix",
|
||||
Dialog::RenameTrack { .. } => "Rename Track",
|
||||
@@ -498,11 +451,15 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
.resizable(false)
|
||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0))
|
||||
.show(ctx, |ui| {
|
||||
// Escape owns the whole frame: no focused button or other control may act first.
|
||||
if esc {
|
||||
return;
|
||||
}
|
||||
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
|
||||
match &mut d {
|
||||
Dialog::NewTracks { count, format, kind, timebase, name } => {
|
||||
if let Some(cmd) = new_tracks_form(ui, count, format, kind, timebase, name, enter) {
|
||||
action = Some(cmd);
|
||||
Dialog::NewTracks(tracks) => {
|
||||
if let Some(params) = tracks.show(ui, enter) {
|
||||
action = Some(("track.new".into(), params));
|
||||
}
|
||||
}
|
||||
Dialog::NewSession { name, sample_rate, demo } => {
|
||||
@@ -550,11 +507,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
);
|
||||
if buttons(ui, "Bounce", enter) {
|
||||
let p = std::path::Path::new(path.as_str()).with_extension(format.default_extension());
|
||||
crate::bounce::start(
|
||||
app,
|
||||
&json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}),
|
||||
bounce_params = Some(
|
||||
json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}),
|
||||
);
|
||||
keep = false;
|
||||
}
|
||||
}
|
||||
Dialog::RenameTrack { id, name } => {
|
||||
@@ -759,14 +714,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
});
|
||||
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::unique("dlg_cancel")).unwrap_or(false)) {
|
||||
keep = false;
|
||||
action = None;
|
||||
bounce_params = None;
|
||||
save_first = None;
|
||||
app.dialogs.after_save = None;
|
||||
app.dialogs.pending_session = None;
|
||||
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::unique("dlg_cancel")));
|
||||
}
|
||||
// Enter on a focused Cancel also presses OK. In Auto Rename, the dialog then closes without running the command.
|
||||
if !keep && matches!(d, Dialog::AutoRename { .. }) {
|
||||
action = None;
|
||||
}
|
||||
if let Some(strip) = rename_from
|
||||
&& keep
|
||||
{
|
||||
@@ -794,6 +748,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
app.dialogs.after_save = Some(then);
|
||||
}
|
||||
if let Some(params) = bounce_params {
|
||||
keep = false;
|
||||
crate::bounce::start(app, ¶ms);
|
||||
}
|
||||
if let Some((cmd, params)) = &action
|
||||
&& !confirmed
|
||||
&& ask_to_save(app, cmd, params)
|
||||
@@ -844,11 +802,13 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
|
||||
}
|
||||
|
||||
/// `buttons`, but a greyed-out OK when the dialog's values can't be submitted yet. Cancel always works.
|
||||
fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool {
|
||||
pub(crate) fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool {
|
||||
let mut pressed = enter && ready;
|
||||
let mut canceled = false;
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Cancel").clicked() {
|
||||
canceled = true;
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::unique("dlg_cancel"), true));
|
||||
}
|
||||
let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent);
|
||||
@@ -856,7 +816,7 @@ fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> boo
|
||||
pressed = true;
|
||||
}
|
||||
});
|
||||
pressed
|
||||
pressed && !canceled
|
||||
}
|
||||
|
||||
/// A name field that takes the focus with its text selected, so typing replaces the old name.
|
||||
@@ -915,6 +875,238 @@ fn loop_length(s: &Session, text: &str, format: soundcraft_time::TimeFormat, sta
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dialogs::new_tracks::set_new_track_kind;
|
||||
|
||||
/// Opens and settles the dialog for a chosen desktop operating system.
|
||||
fn tracks_dialog(os: egui::os::OperatingSystem) -> (egui::Context, SoundApp) {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_os(os);
|
||||
crate::fonts::install(&ctx, true);
|
||||
let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
(ctx, app)
|
||||
}
|
||||
|
||||
/// Runs one dialog frame with synthetic input events.
|
||||
fn dialog_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<egui::Event>) {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
|
||||
let mut output = ctx.run_ui(raw, |ui| {
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
show(app, ui.ctx());
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Constructs one non-repeating key-press event for dialog tests.
|
||||
fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
/// Returns every editable value from the active New Tracks row.
|
||||
fn track_fields(app: &SoundApp) -> (u32, &str, &str, &str, &str) {
|
||||
match app.dialogs.open.as_ref().unwrap() {
|
||||
Dialog::NewTracks(tracks) => {
|
||||
let row = tracks.active_row().unwrap();
|
||||
(row.count, &row.format, &row.kind, &row.timebase, &row.name)
|
||||
}
|
||||
other => panic!("unexpected dialog: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opening selects the count, and bare arrows adjust it within its bounds.
|
||||
#[test]
|
||||
fn new_tracks_focus_selects_count_and_arrows_clamp_it() {
|
||||
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
|
||||
dialog_frame(&ctx, &mut app, vec![egui::Event::Text("7".into())]);
|
||||
assert_eq!(track_fields(&app).0, 7, "typing must replace the selected initial value");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]);
|
||||
assert_eq!(track_fields(&app).0, 8);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]);
|
||||
assert_eq!(track_fields(&app).0, 7);
|
||||
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
|
||||
tracks.rows[0].count = 128;
|
||||
}
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]);
|
||||
assert_eq!(track_fields(&app).0, 128);
|
||||
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
|
||||
tracks.rows[0].count = 1;
|
||||
}
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]);
|
||||
assert_eq!(track_fields(&app).0, 1);
|
||||
}
|
||||
|
||||
/// macOS command-arrow families edit format, type and timebase without changing count.
|
||||
#[test]
|
||||
fn new_tracks_mac_chords_change_settings_without_changing_count() {
|
||||
for ctrl in [false, true] {
|
||||
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
|
||||
let cmd = egui::Modifiers { command: true, mac_cmd: true, ctrl, ..Default::default() };
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]);
|
||||
assert_eq!(track_fields(&app), (1, "Stereo", "audio", "samples", "Audio"));
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]);
|
||||
assert_eq!(track_fields(&app).1, "Mono");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]);
|
||||
assert_eq!(track_fields(&app).1, soundcraft_model::ChannelFormat::ALL.last().unwrap().label());
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
|
||||
assert_eq!(track_fields(&app), (1, "Mono", "aux", "samples", "Aux"));
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]);
|
||||
assert_eq!(track_fields(&app).2, "audio");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]);
|
||||
assert_eq!(track_fields(&app).2, "video");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
|
||||
assert_eq!(track_fields(&app).4, "Audio");
|
||||
assert!(app.engine.session().tracks.is_empty());
|
||||
}
|
||||
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
|
||||
let cmd_alt = egui::Modifiers { command: true, mac_cmd: true, alt: true, ..Default::default() };
|
||||
for (arrow, expected) in [(egui::Key::ArrowDown, "ticks"), (egui::Key::ArrowUp, "samples")] {
|
||||
dialog_frame(&ctx, &mut app, vec![key(arrow, cmd_alt)]);
|
||||
assert_eq!(track_fields(&app).3, expected);
|
||||
assert_eq!(track_fields(&app).0, 1);
|
||||
assert_eq!(track_fields(&app).2, "audio");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type changes update default names while preserving names entered by the user.
|
||||
#[test]
|
||||
fn new_tracks_type_changes_preserve_custom_names() {
|
||||
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
|
||||
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
|
||||
tracks.rows[0].name = "Dialogue cinema".into();
|
||||
}
|
||||
let cmd = egui::Modifiers { command: true, mac_cmd: true, ..Default::default() };
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
|
||||
assert_eq!(track_fields(&app).4, "Dialogue cinema");
|
||||
let mut kind = "audio".to_string();
|
||||
let mut name = "Audio".to_string();
|
||||
set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Instrument);
|
||||
assert_eq!(name, "Inst");
|
||||
name = "Foley".to_string();
|
||||
set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Aux);
|
||||
assert_eq!(name, "Foley");
|
||||
}
|
||||
|
||||
/// Enter creates, while Escape cancels even when both keys arrive in one frame.
|
||||
#[test]
|
||||
fn new_tracks_enter_creates_and_escape_cancels_on_each_platform() {
|
||||
for os in [egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
|
||||
let (ctx, mut app) = tracks_dialog(os);
|
||||
dialog_frame(&ctx, &mut app, vec![egui::Event::Text("3".into())]);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
assert!(app.dialogs.open.is_none());
|
||||
assert_eq!(app.engine.session().tracks.len(), 3);
|
||||
assert!(app.engine.session().markers.is_empty(), "Enter must not add a background marker");
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
|
||||
dialog_frame(&ctx, &mut app, vec![]);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]);
|
||||
assert!(app.dialogs.open.is_none());
|
||||
assert_eq!(app.engine.session().tracks.len(), 3);
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
|
||||
dialog_frame(&ctx, &mut app, vec![]);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
|
||||
assert!(app.dialogs.open.is_none());
|
||||
assert_eq!(app.engine.session().tracks.len(), 3, "Escape must cancel a simultaneous Enter");
|
||||
}
|
||||
}
|
||||
|
||||
/// Row shortcuts change the form without submitting when Enter arrives in the same frame.
|
||||
#[test]
|
||||
fn new_tracks_row_shortcuts_take_precedence_over_simultaneous_enter() {
|
||||
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
|
||||
let rows = egui::Modifiers { command: true, mac_cmd: true, shift: true, ..Default::default() };
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
|
||||
assert_eq!(tracks.rows.len(), 1, "the last row cannot be removed");
|
||||
assert!(app.engine.session().tracks.is_empty());
|
||||
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
|
||||
assert_eq!(tracks.rows.len(), 2);
|
||||
assert!(app.engine.session().tracks.is_empty());
|
||||
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
|
||||
assert_eq!(tracks.rows.len(), 1);
|
||||
assert!(app.engine.session().tracks.is_empty());
|
||||
}
|
||||
|
||||
/// Escape prevents a simultaneous Enter from starting a background bounce.
|
||||
#[test]
|
||||
fn simultaneous_enter_and_escape_does_not_start_a_bounce() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::fonts::install(&ctx, true);
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default());
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix"));
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
|
||||
assert!(app.dialogs.open.is_none());
|
||||
assert!(app.bounce.is_none());
|
||||
}
|
||||
|
||||
/// Escape prevents a simultaneous Save from scheduling the deferred session action.
|
||||
#[test]
|
||||
fn simultaneous_save_and_escape_does_not_continue_after_saving() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::fonts::install(&ctx, true);
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default());
|
||||
let tracks = app.engine.session().tracks.len();
|
||||
app.dialogs.open = Some(Dialog::SaveChanges { then: ("session.close".into(), json!({})) });
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
|
||||
assert!(app.dialogs.open.is_none());
|
||||
assert!(app.dialogs.after_save.is_none());
|
||||
assert_eq!(app.engine.session().tracks.len(), tracks);
|
||||
}
|
||||
|
||||
/// Windows and Linux chords cover every editable field and row action.
|
||||
#[test]
|
||||
fn new_tracks_windows_and_linux_chords_cover_every_field_and_row_action() {
|
||||
for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
|
||||
let (ctx, mut app) = tracks_dialog(os);
|
||||
let ctrl = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
|
||||
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl)]);
|
||||
assert_eq!(track_fields(&app).1, "Stereo");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, ctrl)]);
|
||||
assert_eq!(track_fields(&app).1, "Mono");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl)]);
|
||||
assert_eq!(track_fields(&app).2, "aux");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl)]);
|
||||
assert_eq!(track_fields(&app).2, "audio");
|
||||
|
||||
let ctrl_alt = egui::Modifiers { alt: true, ..ctrl };
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl_alt)]);
|
||||
assert_eq!(track_fields(&app).3, "ticks");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl_alt)]);
|
||||
assert_eq!(track_fields(&app).3, "samples");
|
||||
|
||||
for alt in [false, true] {
|
||||
let rows = egui::Modifiers { shift: true, alt, ..ctrl };
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows)]);
|
||||
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") };
|
||||
assert_eq!(tracks.rows.len(), 2);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows)]);
|
||||
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") };
|
||||
assert_eq!(tracks.rows.len(), 1);
|
||||
}
|
||||
|
||||
let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open else { panic!("missing dialog") };
|
||||
tracks.rows[0].kind = "video".into();
|
||||
tracks.rows[0].timebase = "ticks".into();
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl), key(egui::Key::ArrowUp, ctrl_alt)]);
|
||||
assert_eq!(track_fields(&app).1, "Mono", "Video has no format control");
|
||||
assert_eq!(track_fields(&app).3, "ticks", "Video has no timebase control");
|
||||
}
|
||||
}
|
||||
|
||||
/// A name that is not valid Unicode is not opened under a lossy rendering (another file): the
|
||||
/// status line names its bytes.
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
//! One or more independent New Tracks rows, submitted as a single engine command.
|
||||
|
||||
use egui::{Event, Key, TextStyle, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ChannelFormat, TrackKind};
|
||||
|
||||
const MAX_TRACKS: usize = 128;
|
||||
const NAME_WIDTH: f32 = 260.0;
|
||||
const ROW_BUTTON_WIDTH: f32 = 22.0;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// One independently configured row in the New Tracks dialog.
|
||||
pub struct NewTrackRow {
|
||||
key: u64,
|
||||
pub count: u32,
|
||||
pub format: String,
|
||||
pub kind: String,
|
||||
pub timebase: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Default for NewTrackRow {
|
||||
/// Starts one mono audio row with the program defaults.
|
||||
fn default() -> Self {
|
||||
Self { key: 0, count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl NewTrackRow {
|
||||
/// Builds one `track.new` row without values belonging to hidden controls.
|
||||
fn params(&self) -> Value {
|
||||
let mut params = json!({"count": self.count, "kind": self.kind, "name": self.name});
|
||||
if has_format(&self.kind) {
|
||||
params["format"] = json!(self.format);
|
||||
}
|
||||
if has_timebase(&self.kind) {
|
||||
params["timebase"] = json!(self.timebase);
|
||||
}
|
||||
params
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// State for the multi-row New Tracks dialog.
|
||||
pub struct NewTracks {
|
||||
pub rows: Vec<NewTrackRow>,
|
||||
active: usize,
|
||||
next_key: u64,
|
||||
focus_count: bool,
|
||||
}
|
||||
|
||||
impl Default for NewTracks {
|
||||
/// Starts the dialog with one row whose count field receives focus.
|
||||
fn default() -> Self {
|
||||
Self { rows: vec![NewTrackRow::default()], active: 0, next_key: 1, focus_count: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl NewTracks {
|
||||
/// Returns the row currently targeted by keyboard shortcuts.
|
||||
pub fn active_row(&self) -> Option<&NewTrackRow> {
|
||||
self.rows.get(self.active)
|
||||
}
|
||||
|
||||
/// Inserts a default row after `index` and moves focus to its count.
|
||||
fn insert_after(&mut self, index: usize) {
|
||||
if self.rows.len() >= MAX_TRACKS || self.rows.get(index).is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(next) = self.next_key.checked_add(1) else { return };
|
||||
self.rows.insert(index + 1, NewTrackRow { key: self.next_key, ..NewTrackRow::default() });
|
||||
self.next_key = next;
|
||||
self.active = index + 1;
|
||||
self.focus_count = true;
|
||||
}
|
||||
|
||||
/// Removes `index` while retaining at least one row and a valid active row.
|
||||
fn remove(&mut self, index: usize) {
|
||||
if self.rows.len() <= 1 || self.rows.get(index).is_none() {
|
||||
return;
|
||||
}
|
||||
self.rows.remove(index);
|
||||
if index == self.active {
|
||||
self.active = index.min(self.rows.len().saturating_sub(1));
|
||||
self.focus_count = true;
|
||||
} else if index < self.active {
|
||||
self.active = self.active.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the row, format, type and timebase shortcuts and reports a row action.
|
||||
fn keys(&mut self, ctx: &egui::Context) -> bool {
|
||||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
let mut row_action = false;
|
||||
ctx.input_mut(|i| {
|
||||
i.events.retain(|event| {
|
||||
let Event::Key { key, pressed: true, modifiers: m, .. } = event else { return true };
|
||||
if !m.command {
|
||||
return true;
|
||||
}
|
||||
if m.shift {
|
||||
match key {
|
||||
Key::ArrowDown => self.insert_after(self.active),
|
||||
Key::ArrowUp => self.remove(self.active),
|
||||
_ => return true,
|
||||
}
|
||||
row_action = true;
|
||||
return false;
|
||||
}
|
||||
if m.alt && m.ctrl && mac {
|
||||
return true;
|
||||
}
|
||||
let Some(row) = self.rows.get_mut(self.active) else { return true };
|
||||
let forward = matches!(key, Key::ArrowRight | Key::ArrowDown);
|
||||
match key {
|
||||
Key::ArrowLeft | Key::ArrowRight if !m.alt => {
|
||||
if has_format(&row.kind) {
|
||||
let index = ChannelFormat::ALL.iter().position(|f| f.label() == row.format).unwrap_or(0);
|
||||
if let Some(next) = ChannelFormat::ALL.get(cycle_index(index, ChannelFormat::ALL.len(), forward)) {
|
||||
row.format = next.label().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
Key::ArrowUp | Key::ArrowDown if m.alt => {
|
||||
if has_timebase(&row.kind) {
|
||||
row.timebase = if row.timebase == "ticks" { "samples" } else { "ticks" }.to_string();
|
||||
}
|
||||
}
|
||||
Key::ArrowUp | Key::ArrowDown => {
|
||||
let index = TrackKind::ALL.iter().position(|k| k.id() == row.kind).unwrap_or(0);
|
||||
if let Some(next) = TrackKind::ALL.get(cycle_index(index, TrackKind::ALL.len(), forward)) {
|
||||
set_new_track_kind(&mut row.kind, &mut row.name, *next);
|
||||
}
|
||||
}
|
||||
_ => return true,
|
||||
}
|
||||
false
|
||||
});
|
||||
});
|
||||
row_action
|
||||
}
|
||||
|
||||
/// Draws every creation row and returns one atomic `track.new` request when submitted.
|
||||
pub(crate) fn show(&mut self, ui: &mut Ui, enter: bool) -> Option<Value> {
|
||||
let shortcut_row_action = self.keys(ui.ctx());
|
||||
let widths = RowWidths::new(ui);
|
||||
ui.set_min_width(widths.total(ui));
|
||||
// The old row must not consume text on the first frame after adding a new one.
|
||||
if self.focus_count && ui.is_enabled() && !ui.is_sizing_pass() {
|
||||
ui.memory_mut(|m| {
|
||||
if let Some(id) = m.focused() {
|
||||
m.surrender_focus(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut insert = None;
|
||||
let mut remove = None;
|
||||
let len = self.rows.len();
|
||||
egui::ScrollArea::vertical().id_salt("new_tracks_rows").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (index, row) in self.rows.iter_mut().enumerate() {
|
||||
// Explicit row IDs keep editing state with the row when rows above it are removed.
|
||||
let id = egui::Id::unique(("new_tracks_row", row.key));
|
||||
let rect = egui::Rect::from_min_size(ui.available_rect_before_wrap().min, vec2(ui.available_width(), ui.spacing().interact_size.y));
|
||||
ui.scope_builder(egui::UiBuilder::new().scope_id(id).max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
|
||||
let label = ui.label("Create");
|
||||
let counter = ui
|
||||
.scope_builder(egui::UiBuilder::new().scope_id(id.with("count")), |ui| {
|
||||
ui.spacing_mut().interact_size.x = widths.count;
|
||||
if self.focus_count && index == self.active && ui.is_enabled() && !ui.is_sizing_pass() {
|
||||
ui.memory_mut(|m| m.request_focus(ui.next_auto_id()));
|
||||
self.focus_count = false;
|
||||
}
|
||||
ui.add(egui::DragValue::new(&mut row.count).speed(1.0).range(1..=128)).labelled_by(label.id)
|
||||
})
|
||||
.inner;
|
||||
row.count = row.count.clamp(1, 128);
|
||||
if counter.has_focus() || counter.clicked() {
|
||||
self.active = index;
|
||||
}
|
||||
if counter.gained_focus() {
|
||||
counter.scroll_to_me(Some(egui::Align::Center));
|
||||
}
|
||||
ui.label("new");
|
||||
let format = if has_format(&row.kind) {
|
||||
Some(
|
||||
craft_ui::ComboBox::from_id_salt("nt_fmt")
|
||||
.width(widths.format)
|
||||
.selected_text(&row.format)
|
||||
.show_ui(ui, |ui| {
|
||||
for f in ChannelFormat::ALL {
|
||||
ui.selectable_value(&mut row.format, f.label().to_string(), f.label());
|
||||
}
|
||||
})
|
||||
.response
|
||||
.accessible_name("Channel format"),
|
||||
)
|
||||
} else {
|
||||
ui.allocate_space(vec2(widths.format, ui.spacing().interact_size.y));
|
||||
None
|
||||
};
|
||||
let kind = craft_ui::ComboBox::from_id_salt("nt_kind")
|
||||
.width(widths.kind)
|
||||
.selected_text(TrackKind::from_id(&row.kind).map_or("Audio Track", |k| k.label()))
|
||||
.show_ui(ui, |ui| {
|
||||
for k in TrackKind::ALL {
|
||||
if ui.selectable_label(row.kind == k.id(), k.label()).clicked() {
|
||||
set_new_track_kind(&mut row.kind, &mut row.name, k);
|
||||
}
|
||||
}
|
||||
})
|
||||
.response
|
||||
.accessible_name("Track kind");
|
||||
let timebase = if has_timebase(&row.kind) {
|
||||
ui.label("in");
|
||||
Some(
|
||||
craft_ui::ComboBox::from_id_salt("nt_tb")
|
||||
.width(widths.timebase)
|
||||
.selected_text(if row.timebase == "ticks" { "Ticks" } else { "Samples" })
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut row.timebase, "samples".to_string(), "Samples");
|
||||
ui.selectable_value(&mut row.timebase, "ticks".to_string(), "Ticks");
|
||||
})
|
||||
.response
|
||||
.accessible_name("Timebase"),
|
||||
)
|
||||
} else {
|
||||
ui.allocate_space(vec2(text_width(ui, "in"), ui.spacing().interact_size.y));
|
||||
ui.allocate_space(vec2(widths.timebase, ui.spacing().interact_size.y));
|
||||
None
|
||||
};
|
||||
let label = ui.label("Name:");
|
||||
let name = crate::widgets::named_text_field(
|
||||
ui.add(egui::TextEdit::singleline(&mut row.name).id(id.with("name")).desired_width(NAME_WIDTH)).labelled_by(label.id),
|
||||
"Name:",
|
||||
);
|
||||
if format.iter().chain(timebase.iter()).chain([&kind, &name]).any(|r| r.clicked() || r.has_focus()) {
|
||||
self.active = index;
|
||||
}
|
||||
let size = vec2(ROW_BUTTON_WIDTH, ui.spacing().interact_size.y);
|
||||
if ui.add_enabled(len > 1, egui::Button::new("−").min_size(size)).on_hover_text("Remove this row").clicked() {
|
||||
remove = Some(index);
|
||||
}
|
||||
if ui.add_enabled(len < MAX_TRACKS, egui::Button::new("+").min_size(size)).on_hover_text("Add a row below").clicked() {
|
||||
insert = Some(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let row_action = shortcut_row_action || remove.is_some() || insert.is_some();
|
||||
if let Some(index) = remove {
|
||||
self.active = index;
|
||||
self.remove(index);
|
||||
ui.ctx().request_repaint();
|
||||
} else if let Some(index) = insert {
|
||||
self.insert_after(index);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let total = self.rows.iter().fold(0_u32, |total, row| total.saturating_add(row.count));
|
||||
let can_create = !self.rows.is_empty() && total <= MAX_TRACKS as u32;
|
||||
if !can_create {
|
||||
ui.colored_label(ui.visuals().error_fg_color, "Create up to 128 tracks at a time.");
|
||||
}
|
||||
// Enter on a focused row button changes that row only; it must not also submit the dialog.
|
||||
if crate::dialogs::buttons_enabled(ui, "Create", enter && !row_action, can_create) && !row_action {
|
||||
Some(json!({"rows": self.rows.iter().map(NewTrackRow::params).collect::<Vec<_>>()}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure all choices with the current UI font, rather than the selected label.
|
||||
struct RowWidths {
|
||||
count: f32,
|
||||
format: f32,
|
||||
kind: f32,
|
||||
timebase: f32,
|
||||
}
|
||||
|
||||
impl RowWidths {
|
||||
/// Measures every possible choice so row columns stay fixed while values change.
|
||||
fn new(ui: &Ui) -> Self {
|
||||
Self {
|
||||
count: (styled_text_width(ui, "128", &ui.style().drag_value_text_style) + 2.0 * ui.spacing().button_padding.x)
|
||||
.ceil()
|
||||
.max(ui.spacing().interact_size.x),
|
||||
format: combo_width(ui, ChannelFormat::ALL.iter().map(|f| f.label())),
|
||||
kind: combo_width(ui, TrackKind::ALL.iter().map(|k| k.label())),
|
||||
timebase: combo_width(ui, ["Samples", "Ticks"].into_iter()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the complete width required by one row, including labels and buttons.
|
||||
fn total(&self, ui: &Ui) -> f32 {
|
||||
let labels: f32 = ["Create", "new", "in", "Name:"].iter().map(|s| text_width(ui, s)).sum();
|
||||
labels + self.count + self.format + self.kind + self.timebase + NAME_WIDTH + 2.0 * ROW_BUTTON_WIDTH + 10.0 * ui.spacing().item_spacing.x + 8.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Measures ordinary button text with the current theme and font scale.
|
||||
fn text_width(ui: &Ui, text: &str) -> f32 {
|
||||
styled_text_width(ui, text, &TextStyle::Button)
|
||||
}
|
||||
|
||||
/// Measures one string using the supplied egui text style.
|
||||
fn styled_text_width(ui: &Ui, text: &str, style: &TextStyle) -> f32 {
|
||||
let font = style.resolve(ui.style());
|
||||
ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), font, ui.visuals().text_color()).size().x)
|
||||
}
|
||||
|
||||
/// Computes a stable ComboBox width from all labels it may display.
|
||||
fn combo_width<'a>(ui: &Ui, labels: impl Iterator<Item = &'a str>) -> f32 {
|
||||
let longest = labels.map(|s| text_width(ui, s)).fold(0.0, f32::max);
|
||||
(longest + ui.spacing().icon_width + ui.spacing().icon_spacing + 2.0 * ui.spacing().button_padding.x + 2.0).ceil()
|
||||
}
|
||||
|
||||
/// Changes kind and updates the default name without replacing a custom name.
|
||||
pub(crate) fn set_new_track_kind(kind: &mut String, name: &mut String, next: TrackKind) {
|
||||
if TrackKind::from_id(kind).is_some_and(|old| name == old.default_name()) {
|
||||
*name = next.default_name().to_string();
|
||||
}
|
||||
*kind = next.id().to_string();
|
||||
}
|
||||
|
||||
/// Reports whether a track kind exposes a channel-format control.
|
||||
fn has_format(kind: &str) -> bool {
|
||||
!matches!(TrackKind::from_id(kind), Some(TrackKind::Midi | TrackKind::Vca | TrackKind::Video))
|
||||
}
|
||||
|
||||
/// Reports whether a track kind exposes a samples/ticks control.
|
||||
fn has_timebase(kind: &str) -> bool {
|
||||
!matches!(TrackKind::from_id(kind), Some(TrackKind::Video))
|
||||
}
|
||||
|
||||
/// Moves one position through a circular choice list in either direction.
|
||||
fn cycle_index(index: usize, len: usize, forward: bool) -> usize {
|
||||
if forward {
|
||||
if index.saturating_add(1) < len { index.saturating_add(1) } else { 0 }
|
||||
} else {
|
||||
index.checked_sub(1).unwrap_or(len.saturating_sub(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Services, SoundApp, dialogs::Dialog};
|
||||
use egui::{Modifiers, accesskit::Role};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
/// Opens a settled New Tracks dialog in a macOS UI harness.
|
||||
fn harness() -> Harness<'static, SoundApp> {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.ctx.set_os(egui::os::OperatingSystem::Mac);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// Borrows the dialog model from a test harness.
|
||||
fn tracks<'a>(h: &'a Harness<'_, SoundApp>) -> &'a NewTracks {
|
||||
let Some(Dialog::NewTracks(tracks)) = &h.state().dialogs.open else { panic!("missing New Tracks dialog") };
|
||||
tracks
|
||||
}
|
||||
|
||||
/// Mutably borrows the dialog model from a test harness.
|
||||
fn tracks_mut<'a>(h: &'a mut Harness<'_, SoundApp>) -> &'a mut NewTracks {
|
||||
let Some(Dialog::NewTracks(tracks)) = &mut h.state_mut().dialogs.open else { panic!("missing New Tracks dialog") };
|
||||
tracks
|
||||
}
|
||||
|
||||
/// Sends one keyboard chord and lets a resized dialog settle.
|
||||
fn chord(h: &mut Harness<SoundApp>, m: Modifiers, key: Key) {
|
||||
h.key_press_modifiers(m, key);
|
||||
// A centered window needs another frame to settle after its row count changes.
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// Every selectable label retains its column's maximum measured width.
|
||||
#[test]
|
||||
fn every_choice_keeps_the_same_field_widths_and_name_position() {
|
||||
let mut h = harness();
|
||||
let format = h.get_by_value("Mono").rect();
|
||||
let kind = h.get_by_value("Audio Track").rect();
|
||||
let timebase = h.get_by_value("Samples").rect();
|
||||
let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
assert!(name.width() >= NAME_WIDTH);
|
||||
for f in ChannelFormat::ALL {
|
||||
tracks_mut(&mut h).rows[0].format = f.label().to_string();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_by_value(f.label()).rect().width(), format.width(), "{}", f.label());
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name);
|
||||
}
|
||||
for k in TrackKind::ALL {
|
||||
tracks_mut(&mut h).rows[0].kind = k.id().to_string();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_by_value(k.label()).rect().width(), kind.width(), "{}", k.label());
|
||||
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
|
||||
}
|
||||
tracks_mut(&mut h).rows[0].kind = "audio".to_string();
|
||||
tracks_mut(&mut h).rows[0].timebase = "ticks".to_string();
|
||||
tracks_mut(&mut h).rows[0].count = 128;
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_by_value("Ticks").rect().width(), timebase.width());
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name);
|
||||
h.ctx.global_style_mut(|s| s.drag_value_text_style = TextStyle::Heading);
|
||||
tracks_mut(&mut h).rows[0].count = 1;
|
||||
h.run_steps(4);
|
||||
let larger_font_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
tracks_mut(&mut h).rows[0].count = 128;
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), larger_font_name);
|
||||
}
|
||||
|
||||
/// Hidden controls retain their layout space for kinds that do not use them.
|
||||
#[test]
|
||||
fn inapplicable_fields_are_hidden_without_moving_the_other_columns() {
|
||||
let mut h = harness();
|
||||
let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
let all_fields = h.get_all_by_role(Role::ComboBox).count();
|
||||
|
||||
for kind in [TrackKind::Midi, TrackKind::Vca] {
|
||||
tracks_mut(&mut h).rows[0].kind = kind.id().to_string();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 1, "{}", kind.label());
|
||||
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
|
||||
}
|
||||
|
||||
tracks_mut(&mut h).rows[0].kind = TrackKind::Video.id().to_string();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 2);
|
||||
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
|
||||
}
|
||||
|
||||
/// Submitted rows omit controls that are hidden for their selected track kind.
|
||||
#[test]
|
||||
fn inapplicable_fields_are_omitted_from_row_parameters() {
|
||||
let row = NewTrackRow {
|
||||
count: 2,
|
||||
format: "Stereo".into(),
|
||||
kind: TrackKind::Midi.id().into(),
|
||||
timebase: "ticks".into(),
|
||||
name: "MIDI".into(),
|
||||
..NewTrackRow::default()
|
||||
};
|
||||
assert_eq!(row.params(), json!({"count": 2, "kind": "midi", "timebase": "ticks", "name": "MIDI"}));
|
||||
|
||||
let row = NewTrackRow { kind: TrackKind::Vca.id().into(), name: "VCA".into(), ..row };
|
||||
assert_eq!(row.params(), json!({"count": 2, "kind": "vca", "timebase": "ticks", "name": "VCA"}));
|
||||
|
||||
let row = NewTrackRow { kind: TrackKind::Video.id().into(), name: "Video".into(), ..row };
|
||||
assert_eq!(row.params(), json!({"count": 2, "kind": "video", "name": "Video"}));
|
||||
}
|
||||
|
||||
/// Both supported add/remove shortcut variants preserve independent row state.
|
||||
#[test]
|
||||
fn both_requested_row_chords_focus_the_new_count_and_preserve_other_rows() {
|
||||
for alt in [false, true] {
|
||||
let mut h = harness();
|
||||
let row_chord = Modifiers { command: true, mac_cmd: true, ctrl: true, shift: true, alt };
|
||||
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
|
||||
chord(&mut h, row_chord, Key::ArrowDown);
|
||||
assert_eq!(tracks(&h).rows.len(), 2);
|
||||
assert_eq!(tracks(&h).active, 1);
|
||||
h.event(Event::Text("7".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(tracks(&h).rows[1].count, 7);
|
||||
assert_eq!(tracks(&h).rows[0].count, 1);
|
||||
assert_eq!(tracks(&h).rows[0].name, "Dialogue");
|
||||
chord(&mut h, Modifiers { command: true, mac_cmd: true, ..Default::default() }, Key::ArrowDown);
|
||||
assert_eq!(tracks(&h).rows[1].kind, "aux");
|
||||
assert_eq!(tracks(&h).rows[0].kind, "audio");
|
||||
chord(&mut h, row_chord, Key::ArrowUp);
|
||||
assert_eq!(tracks(&h).rows.len(), 1);
|
||||
assert_eq!(tracks(&h).rows[0].name, "Dialogue");
|
||||
chord(&mut h, row_chord, Key::ArrowUp);
|
||||
assert_eq!(tracks(&h).rows.len(), 1, "the last row cannot be removed");
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Each row's add and remove buttons act at that row's position.
|
||||
#[test]
|
||||
fn plus_and_minus_buttons_operate_on_their_own_row() {
|
||||
let mut h = harness();
|
||||
h.get_by_role_and_label(Role::Button, "+").click();
|
||||
h.run_steps(4);
|
||||
let names: Vec<_> = h.get_all_by_role_and_label(Role::TextInput, "Name:").map(|n| n.rect()).collect();
|
||||
assert_eq!(names.len(), 2);
|
||||
assert_eq!(names[0].min.x, names[1].min.x);
|
||||
assert_eq!(names[0].width(), names[1].width());
|
||||
assert!(names[1].min.y - names[0].min.y <= 32.0, "rows must stay compact: {names:?}");
|
||||
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
|
||||
tracks_mut(&mut h).rows[1].name = "Effects".into();
|
||||
h.get_all_by_role_and_label(Role::Button, "+").next().unwrap().click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), ["Dialogue", "Audio", "Effects"]);
|
||||
h.get_all_by_role_and_label(Role::Button, "−").nth(1).unwrap().click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), ["Dialogue", "Effects"]);
|
||||
}
|
||||
|
||||
/// Enter on a focused row button changes the rows without also creating tracks.
|
||||
#[test]
|
||||
fn enter_on_row_buttons_does_not_submit_the_dialog() {
|
||||
let mut h = harness();
|
||||
h.get_by_role_and_label(Role::Button, "+").focus();
|
||||
chord(&mut h, Modifiers::NONE, Key::Enter);
|
||||
assert_eq!(tracks(&h).rows.len(), 2);
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
|
||||
h.get_all_by_role_and_label(Role::Button, "−").next().unwrap().focus();
|
||||
chord(&mut h, Modifiers::NONE, Key::Enter);
|
||||
assert_eq!(tracks(&h).rows.len(), 1);
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
}
|
||||
|
||||
/// Enter on the focused Cancel button closes the dialog without creating tracks.
|
||||
#[test]
|
||||
fn enter_on_cancel_does_not_submit_the_dialog() {
|
||||
let mut h = harness();
|
||||
h.get_by_role_and_label(Role::Button, "Cancel").focus();
|
||||
chord(&mut h, Modifiers::NONE, Key::Enter);
|
||||
assert!(h.state().dialogs.open.is_none());
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
}
|
||||
|
||||
/// Removing a preceding row does not transfer widget state to another row.
|
||||
#[test]
|
||||
fn removing_an_earlier_row_keeps_editing_state_with_the_surviving_row() {
|
||||
let mut h = harness();
|
||||
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
|
||||
h.event(Event::Text("42".into()));
|
||||
h.run_steps(2);
|
||||
let focused = h.ctx.memory(|m| m.focused());
|
||||
let key = tracks(&h).rows[1].key;
|
||||
tracks_mut(&mut h).remove(0);
|
||||
h.run_steps(2);
|
||||
assert_eq!(tracks(&h).rows[0].key, key);
|
||||
assert_eq!(tracks(&h).rows[0].count, 42);
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), focused);
|
||||
chord(&mut h, Modifiers::NONE, Key::ArrowUp);
|
||||
assert_eq!(tracks(&h).rows[0].count, 43);
|
||||
}
|
||||
|
||||
/// A clicked row receives later shortcuts without altering its custom name.
|
||||
#[test]
|
||||
fn clicking_a_different_row_targets_its_chords_and_keeps_its_custom_name() {
|
||||
let mut h = harness();
|
||||
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
|
||||
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
|
||||
h.get_all_by_role_and_label(Role::TextInput, "Name:").next().unwrap().click();
|
||||
h.run_steps(2);
|
||||
let cmd = Modifiers { command: true, mac_cmd: true, ..Default::default() };
|
||||
chord(&mut h, cmd, Key::ArrowDown);
|
||||
chord(&mut h, cmd, Key::ArrowRight);
|
||||
chord(&mut h, Modifiers { alt: true, ..cmd }, Key::ArrowUp);
|
||||
let rows = &tracks(&h).rows;
|
||||
assert_eq!(
|
||||
(&rows[0].kind, &rows[0].format, &rows[0].timebase, &rows[0].name),
|
||||
(&"aux".to_string(), &"Stereo".to_string(), &"ticks".to_string(), &"Dialogue".to_string())
|
||||
);
|
||||
assert_eq!((&rows[1].kind, &rows[1].format, &rows[1].timebase), (&"audio".to_string(), &"Mono".to_string(), &"samples".to_string()));
|
||||
assert_eq!((rows[0].count, rows[1].count), (1, 1));
|
||||
}
|
||||
|
||||
/// Submitting several rows creates and undoes them as one engine action.
|
||||
#[test]
|
||||
fn create_submits_every_row_and_undo_removes_the_whole_creation() {
|
||||
let mut h = harness();
|
||||
tracks_mut(&mut h).rows[0].count = 2;
|
||||
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
|
||||
let row = &mut tracks_mut(&mut h).rows[1];
|
||||
row.kind = "aux".into();
|
||||
row.format = "Stereo".into();
|
||||
row.name = "Reverb".into();
|
||||
chord(&mut h, Modifiers::NONE, Key::Enter);
|
||||
assert!(h.state().dialogs.open.is_none());
|
||||
assert_eq!(
|
||||
h.state().engine.session().tracks.iter().map(|t| t.kind).collect::<Vec<_>>(),
|
||||
[TrackKind::Audio, TrackKind::Audio, TrackKind::Aux]
|
||||
);
|
||||
assert_eq!(h.state().engine.session().tracks[2].name, "Reverb");
|
||||
assert!(h.state().engine.session().markers.is_empty());
|
||||
assert!(h.state_mut().engine.undo());
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
assert!(!h.state().engine.can_undo());
|
||||
}
|
||||
|
||||
/// The aggregate 128-track limit disables creation while leaving cancellation usable.
|
||||
#[test]
|
||||
fn too_many_tracks_disable_create_but_leave_cancel_available() {
|
||||
let mut h = harness();
|
||||
tracks_mut(&mut h).rows[0].count = 128;
|
||||
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
|
||||
chord(&mut h, Modifiers::NONE, Key::Enter);
|
||||
assert!(h.state().dialogs.open.is_some());
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
h.get_by_role_and_label(Role::Button, "Cancel").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialogs.open.is_none());
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ pub(super) fn fade_curve_drag_value(fade: soundcraft_model::Fade, anchor: (f32,
|
||||
|
||||
/// Runs before anything is painted, against the last frame's layout.
|
||||
pub(super) fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. })) {
|
||||
if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) {
|
||||
return;
|
||||
}
|
||||
let (s, tl) = (app.engine.session_arc(), rect(app.edit_layout.timeline));
|
||||
|
||||
@@ -115,8 +115,18 @@ fn name_plate(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, select
|
||||
track.name.clone()
|
||||
};
|
||||
let name_color = if selected { t.text_dark } else { t.text.lerp_to_gamma(t.text_dim, 0.4) };
|
||||
ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER, &shown_name, bold(12.5), name_color);
|
||||
let nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag());
|
||||
let font = bold(12.5);
|
||||
let galley = ui.painter().layout_no_wrap(shown_name.clone(), font.clone(), name_color);
|
||||
let overflows = galley.size().x > name_r.width() - 3.0;
|
||||
let (text_pos, align) =
|
||||
if overflows { (pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER) } else { (name_r.center(), Align2::CENTER_CENTER) };
|
||||
ui.painter().with_clip_rect(name_r).text(text_pos, align, &shown_name, font, name_color);
|
||||
let mut nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag());
|
||||
if overflows {
|
||||
nresp = nresp.on_hover_ui(|ui| {
|
||||
ui.label(&shown_name);
|
||||
});
|
||||
}
|
||||
if nresp.double_clicked() {
|
||||
app.dialogs.open_rename_track(id, &track.name);
|
||||
} else if nresp.clicked() {
|
||||
@@ -190,8 +200,9 @@ fn toggle_keys(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
|
||||
let h = head.height();
|
||||
let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h));
|
||||
let m = &track.mixer;
|
||||
if h >= 37.0 {
|
||||
let (records, mixes) = (track.kind.has_playlist(), track.kind != TrackKind::Master);
|
||||
let is_master = track.kind == TrackKind::Master;
|
||||
if h >= 37.0 && !is_master {
|
||||
let (records, mixes) = (track.kind.has_playlist(), true);
|
||||
let implied = !m.solo && app.engine.session().tracks.iter().any(|t| t.mixer.solo);
|
||||
let keys = [
|
||||
(HeaderKey::Rec, m.record_arm, false, "mix.record_arm", records),
|
||||
@@ -213,10 +224,13 @@ fn view_selector(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
|
||||
let id = track.id;
|
||||
let h = head.height();
|
||||
let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h));
|
||||
if h >= 58.0 {
|
||||
let is_master = track.kind == TrackKind::Master;
|
||||
let view_y = if is_master { 20.0 } else { 39.0 };
|
||||
let view_min_h = if is_master { 37.0 } else { 58.0 };
|
||||
if h >= view_min_h {
|
||||
let vresp = header_selector(
|
||||
ui,
|
||||
at(17.0, 39.0, 105.0, 17.0),
|
||||
at(17.0, view_y, 105.0, 17.0),
|
||||
ui.scope_id().with(("view", id.0)),
|
||||
&view_label(&track.view),
|
||||
t.text,
|
||||
@@ -225,10 +239,32 @@ fn view_selector(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
|
||||
);
|
||||
egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track));
|
||||
}
|
||||
if is_master && h >= 58.0 {
|
||||
let mode = track.mixer.automation_mode;
|
||||
let col = match mode {
|
||||
soundcraft_model::AutomationMode::Read => t.auto_read,
|
||||
soundcraft_model::AutomationMode::Write => t.auto_write,
|
||||
soundcraft_model::AutomationMode::Touch | soundcraft_model::AutomationMode::TouchLatch => t.auto_touch,
|
||||
soundcraft_model::AutomationMode::Latch => t.auto_latch,
|
||||
_ => t.text_dim,
|
||||
};
|
||||
let mode_name = format!("Automation mode for {}", track.name);
|
||||
let aresp = header_selector(ui, at(17.0, 39.0, 105.0, 17.0), ui.scope_id().with(("auto_mode", id.0)), mode.label(), col, true, &mode_name);
|
||||
egui::Popup::menu(&aresp).show(|ui| {
|
||||
for m in soundcraft_model::AutomationMode::ALL {
|
||||
if ui.selectable_label(m == mode, m.label()).clicked() {
|
||||
let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Freeze, voice and automation mode.
|
||||
fn voice_row(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
|
||||
if track.kind == TrackKind::Master {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::current();
|
||||
let id = track.id;
|
||||
let h = head.height();
|
||||
|
||||
@@ -511,7 +511,9 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
|
||||
}
|
||||
return;
|
||||
};
|
||||
let ids = json!([c.0]);
|
||||
// A clip of the edit selection acts on the whole selection (the commands default to it); any other clip acts alone.
|
||||
let in_selection = clip_in_selection(app.engine.session(), c);
|
||||
let target = if in_selection { json!({}) } else { json!({"clips": [c.0]}) };
|
||||
for (label, cmd) in [
|
||||
("Mute/Unmute", "edit.mute_clips"),
|
||||
("Edit Lock/Unlock", "clip.edit_lock"),
|
||||
@@ -522,7 +524,7 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
|
||||
("Send to Back", "clip.send_to_back"),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
let _ = app.run(cmd, json!({"clips": ids}));
|
||||
let _ = app.run(cmd, target.clone());
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
@@ -531,7 +533,28 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
|
||||
app.dialogs.open_rename_clip(c, &name);
|
||||
}
|
||||
if ui.button("Delete").clicked() {
|
||||
let _ = app.run("edit.select", json!({"clips": ids}));
|
||||
let _ = app.run("edit.clear", json!({}));
|
||||
let s = app.engine.session();
|
||||
let selected: Vec<u64> = s.edit.selected_clips.iter().map(|id| id.0).collect();
|
||||
let track: Vec<u64> = s.find_clip(c).map(|(t, _)| t.0).into_iter().collect();
|
||||
if !in_selection {
|
||||
let _ = app.run("edit.select", json!({"clips": [c.0]}));
|
||||
let _ = app.run("edit.clear", json!({}));
|
||||
} else if selected.is_empty() {
|
||||
let _ = app.run("edit.clear", json!({}));
|
||||
} else {
|
||||
// Selected clips go by id (an empty range), so clips between them that the span covers stay. The clicked
|
||||
// clip's track satisfies Clear's track check even when the track selection was emptied meanwhile.
|
||||
let _ = app.run("edit.clear", json!({"clips": selected, "tracks": track, "start": 0}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `c` is part of the edit selection: one of the selected clips or, when no clip is selected, a clip on a
|
||||
/// selected track that the selected range overlaps. An insertion point selects nothing.
|
||||
fn clip_in_selection(s: &Session, c: ClipId) -> bool {
|
||||
let ed = &s.edit;
|
||||
if !ed.selected_clips.is_empty() {
|
||||
return ed.selected_clips.contains(&c);
|
||||
}
|
||||
!ed.selection.is_empty() && s.find_clip(c).is_some_and(|(t, clip)| ed.selected_tracks.contains(&t) && clip.range().overlaps(&ed.selection))
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ impl EditLayout {
|
||||
pub fn scroll_by(&mut self, dy: f32, view_h: f32) {
|
||||
self.scroll_y = (self.scroll_y + dy).clamp(0.0, (self.content_h - view_h + 40.0).max(0.0));
|
||||
}
|
||||
|
||||
/// Clears the vertical scroll position Zoom Toggle would restore, so Cancel Zoom Toggle keeps the current scroll.
|
||||
pub(crate) fn forget_zoom_back(&mut self) {
|
||||
self.zoom_back = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rect([x0, y0, x1, y1]: [f32; 4]) -> Rect {
|
||||
|
||||
@@ -222,7 +222,8 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect,
|
||||
} else if resp.clicked()
|
||||
&& let Some(m) = hit
|
||||
{
|
||||
let _ = app.run("markers.recall", json!({"number": m.number}));
|
||||
let command = if ui.input(|i| i.modifiers.alt) { "markers.delete" } else { "markers.recall" };
|
||||
let _ = app.run(command, json!({"number": m.number}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +75,13 @@ pub(super) fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Zoom Toggle sized to the track area on screen.
|
||||
/// Zoom Toggle sized to the track area on screen. With Alt held (an Alt-click on the button) it cancels Zoom Toggle
|
||||
/// instead and keeps the current view.
|
||||
pub fn zoom_toggle(app: &mut SoundApp) {
|
||||
if app.docking_context.as_ref().is_some_and(|c| c.input(|i| i.modifiers.alt)) {
|
||||
let _ = app.run("view.zoom_toggle_cancel", json!({}));
|
||||
return;
|
||||
}
|
||||
let [x0, y0, x1, y1] = app.edit_layout.timeline;
|
||||
let _ = app.run("view.zoom_toggle", json!({"width_px": x1 - x0, "height_px": y1 - y0}));
|
||||
}
|
||||
|
||||
@@ -246,6 +246,16 @@ fn hold_clears_when_playhead_returns_or_transport_stops() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel Zoom Toggle with Zoom Toggle already off keeps the scroll position a pending toggle-off restores.
|
||||
#[test]
|
||||
fn a_cancel_with_zoom_toggle_off_keeps_the_scroll_to_restore() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
|
||||
app.edit_layout.zoom_back = Some(120.0);
|
||||
let v = app.run("view.zoom_toggle_cancel", json!({})).unwrap();
|
||||
assert_eq!(v["canceled"], json!(false));
|
||||
assert_eq!(app.edit_layout.zoom_back, Some(120.0));
|
||||
}
|
||||
|
||||
fn harness(n: usize) -> Harness<'static, SoundApp> {
|
||||
let mut engine = soundcraft_engine::Engine::default();
|
||||
engine.execute("track.new", &json!({"count": n})).unwrap();
|
||||
@@ -1498,3 +1508,50 @@ fn alt_click_on_a_breakpoint_deletes_it() {
|
||||
assert_eq!(h.state().engine.session().track(track).unwrap().clips(), clips.as_slice(), "{tool}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_on_a_marker_deletes_it() {
|
||||
let mut engine = soundcraft_engine::demo::demo_engine();
|
||||
engine.session_mut().edit.rulers = vec!["markers".into()];
|
||||
engine.execute("markers.add", &json!({"at": 48_000, "name": "Alt-delete me"})).unwrap();
|
||||
let mut h = harness_with(engine);
|
||||
let s = h.state().engine.session_arc();
|
||||
let marker = s.markers.iter().find(|m| m.name == "Alt-delete me").unwrap();
|
||||
let at = pos2(x_of(&s, timeline(&h), marker.start), timeline(&h).min.y - 11.5);
|
||||
let before = s.markers.len();
|
||||
|
||||
click_with(&mut h, at, egui::Modifiers::ALT);
|
||||
|
||||
assert_eq!(h.state().engine.session().markers.len(), before - 1);
|
||||
assert!(!h.state().engine.session().markers.iter().any(|m| m.name == "Alt-delete me"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_track_name_is_centered_and_long_name_is_left_aligned() {
|
||||
let mut h = harness(2);
|
||||
let s = h.state().engine.session_arc();
|
||||
let first = s.tracks[0].id.0;
|
||||
let second = s.tracks[1].id.0;
|
||||
h.state_mut().run("track.rename", json!({"track": first, "name": "Vocal"})).unwrap();
|
||||
h.state_mut().run("track.rename", json!({"track": second, "name": "A Very Long Track Name That Exceeds Header Width"})).unwrap();
|
||||
h.step();
|
||||
|
||||
let r0 = row(&h, 0);
|
||||
let r1 = row(&h, 1);
|
||||
|
||||
let first_text = h.output().shapes.iter().find_map(|s| match &s.shape {
|
||||
Shape::Text(t) if t.galley.text() == "Vocal" && s.clip_rect.intersects(r0) => Some((t.pos, t.galley.size())),
|
||||
_ => None,
|
||||
});
|
||||
let long_text = h.output().shapes.iter().find_map(|s| match &s.shape {
|
||||
Shape::Text(t) if t.galley.text() == "A Very Long Track Name That Exceeds Header Width" && s.clip_rect.intersects(r1) => Some(t.pos),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let (first_pos, first_size) = first_text.expect("short track name should be rendered in edit header");
|
||||
let long_pos = long_text.expect("long track name should be rendered in edit header");
|
||||
|
||||
assert_eq!(long_pos.x, r1.min.x + 17.5);
|
||||
let first_center_x = first_pos.x + first_size.x * 0.5;
|
||||
assert!((first_center_x - (r0.min.x + 64.5)).abs() <= 0.1, "short name should be centered (center was at {first_center_x})");
|
||||
}
|
||||
|
||||
@@ -176,6 +176,19 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) {
|
||||
painter.line_segment([p(r, x, 0.5 - h), p(r, x, 0.5 + h)], s);
|
||||
}
|
||||
}
|
||||
"link_track" => {
|
||||
// A link chain below two track rows, so the track link reads apart from the timeline link's plain chain.
|
||||
painter.line_segment([p(r, 0.12, 0.18), p(r, 0.88, 0.18)], s);
|
||||
painter.line_segment([p(r, 0.12, 0.32), p(r, 0.88, 0.32)], s);
|
||||
painter.circle_stroke(p(r, 0.38, 0.72), r.width() * 0.16, s);
|
||||
painter.circle_stroke(p(r, 0.62, 0.72), r.width() * 0.16, s);
|
||||
}
|
||||
"insertion_follows" => {
|
||||
// The insertion line, a lead, and the playhead arrow it follows.
|
||||
painter.line_segment([p(r, 0.3, 0.14), p(r, 0.3, 0.86)], s);
|
||||
painter.line_segment([p(r, 0.42, 0.5), p(r, 0.62, 0.5)], s);
|
||||
painter.add(Shape::convex_polygon(vec![p(r, 0.84, 0.5), p(r, 0.64, 0.34), p(r, 0.64, 0.66)], c, Stroke::NONE));
|
||||
}
|
||||
_ => {
|
||||
painter.rect_stroke(r.shrink(r.width() * 0.25), 1.0, s, egui::StrokeKind::Middle);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
//! executing commands. It can be replaced without touching anything below it.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
/// SoundCraft as craft-update knows it, with its 128 px icon and accent colour: the update
|
||||
/// prompt shows both, and an AppImage's menu entry gets the icon.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub const UPDATE_APP: craft_update::app::App = craft_update::app::App {
|
||||
icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.soundcraft.png")),
|
||||
accent: Some([0x1f, 0xa5, 0xc9]),
|
||||
..craft_update::suite::SOUNDCRAFT
|
||||
};
|
||||
|
||||
pub mod appearance;
|
||||
pub mod bounce;
|
||||
pub mod control;
|
||||
@@ -235,6 +244,7 @@ pub enum Gesture {
|
||||
FadeCurve { clip: soundcraft_model::ClipId, fade_in: bool, curvature: f32, anchor: (f32, f32), height: f32 },
|
||||
Scrub { last: Samples },
|
||||
Fader { track: TrackId, start_db: f32 },
|
||||
SendFader { track: TrackId, slot: usize },
|
||||
ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 },
|
||||
Pencil { track: TrackId, points: Vec<(Samples, f32)> },
|
||||
Breakpoint { track: TrackId, param: soundcraft_model::AutoParam, at: Samples },
|
||||
@@ -384,6 +394,8 @@ pub struct SoundApp {
|
||||
pub arrange_request: bool,
|
||||
/// Tracks whose automation was touched during this pass (Touch/Latch writing).
|
||||
touched: std::collections::HashSet<TrackId>,
|
||||
/// Sends whose level was touched during this pass (Touch/Latch writing), as (track, slot).
|
||||
touched_sends: std::collections::HashSet<(TrackId, usize)>,
|
||||
/// The pass's last point; `i64::MIN` after a stop or a jump, so the next point erases nothing it skipped.
|
||||
last_write_at: Samples,
|
||||
/// Plugins changed in their editors whose state the session hasn't taken yet (see `editor_states_due`).
|
||||
|
||||
@@ -11,7 +11,12 @@ pub(crate) fn route_name(app: &SoundApp, r: &Route) -> String {
|
||||
}
|
||||
|
||||
pub(crate) fn input_name(app: &SoundApp, track: &Track) -> String {
|
||||
if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) }
|
||||
if track.kind.has_playlist() && track.mixer.input == Route::None {
|
||||
let channels = track.format.channels();
|
||||
if channels == 1 { "In 1".to_string() } else { format!("In 1-{channels}") }
|
||||
} else {
|
||||
route_name(app, &track.mixer.input)
|
||||
}
|
||||
}
|
||||
|
||||
/// The input or output menu under `resp`. It closes only on a click outside, so its bus search
|
||||
@@ -59,3 +64,19 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::input_name;
|
||||
use crate::{Services, SoundApp};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn stereo_track_defaults_to_a_stereo_input_pair() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("track.new", json!({"format": "stereo"})).unwrap();
|
||||
let track = &app.engine.session().tracks[0];
|
||||
|
||||
assert_eq!(input_name(&app, track), "In 1-2");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user