mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-31 14:06:56 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
444a716396 | ||
|
|
ba86e7eae7 | ||
|
|
54d36c2fcc | ||
|
|
382965153a | ||
|
|
2de7846d4b | ||
|
|
eb095cbe81 | ||
|
|
ef2f70eb35 | ||
|
|
e60a559596 | ||
|
|
4716141401 | ||
|
|
314be8740e | ||
|
|
675673346b | ||
|
|
ba3b191440 | ||
|
|
60cd0622f2 | ||
|
|
87836c3ec2 | ||
|
|
c896668d00 | ||
|
|
deceb0cd3a | ||
|
|
6446eb5598 | ||
|
|
a4e4a44bab | ||
|
|
99895de2c7 | ||
|
|
48f61d297e | ||
|
|
6e2da18e9e | ||
|
|
13b2488e95 | ||
|
|
6ef3300aba | ||
|
|
ad3147388f | ||
|
|
a13a4f5bd2 | ||
|
|
2d88f46f02 | ||
|
|
f28a10a5dd | ||
|
|
53bb43db46 | ||
|
|
b9fee48a75 | ||
|
|
948460a953 | ||
|
|
4179cf88e6 | ||
|
|
ca6271b082 | ||
|
|
4c31f2884b | ||
|
|
1964662a71 | ||
|
|
5288e42bc9 | ||
|
|
13fe1df4b8 | ||
|
|
b2a386bc7a | ||
|
|
331b1820db | ||
|
|
966e253873 | ||
|
|
af4dd7ce4c | ||
|
|
f7b0805de6 | ||
|
|
cc02045a2d | ||
|
|
ddfe991a87 | ||
|
|
8da40bef96 | ||
|
|
90e8f60bad | ||
|
|
3bd3965c51 | ||
|
|
5a1324b8bc | ||
|
|
552607cbee | ||
|
|
9f5f312051 | ||
|
|
ffb95ebcf3 | ||
|
|
440999a51f | ||
|
|
14a081304b | ||
|
|
dcdcca4704 | ||
|
|
1ffc164082 | ||
|
|
afa1532aba | ||
|
|
84e591e1ca |
@@ -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).
|
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
|
## 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).
|
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.
|
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
|
||||||
|
|
||||||
## ⚠️ Assets: the absolute rule
|
## ⚠️ 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.
|
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
|
## 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"`).
|
- 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 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).
|
- 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.
|
`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
|
## 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
|
||||||
`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.
|
`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,14 +79,11 @@ 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
|
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
|
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
|
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
|
(copy the template at the top of the file)
|
||||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
|
||||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||||
`@username` anyway.
|
`@username` anyway.
|
||||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
- 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`.
|
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
|
## 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.
|
- 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.
|
||||||
|
|||||||
+8
-8
@@ -39,14 +39,14 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts
|
|||||||
|
|
||||||
| Path | Author | Source | License |
|
| 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.svg` | ArtCraft Team | ArtCraft brand assets | 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.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | 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 | craftrules `assets/brand/` | ArtCraft brand terms |
|
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
|
||||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | 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 | craftrules `assets/brand/` | 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 |
|
| `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 |
|
| `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 |
|
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
|
||||||
|
|||||||
Generated
+14
-14
@@ -272,7 +272,7 @@ dependencies = [
|
|||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
"wl-clipboard-rs",
|
"wl-clipboard-rs",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
@@ -1058,17 +1058,17 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-docpath"
|
name = "craft-docpath"
|
||||||
version = "0.1.0"
|
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]]
|
[[package]]
|
||||||
name = "craft-fonts-build"
|
name = "craft-fonts-build"
|
||||||
version = "0.1.0"
|
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]]
|
[[package]]
|
||||||
name = "craft-i18n"
|
name = "craft-i18n"
|
||||||
version = "0.1.0"
|
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 = [
|
dependencies = [
|
||||||
"fixed_decimal",
|
"fixed_decimal",
|
||||||
"icu_decimal",
|
"icu_decimal",
|
||||||
@@ -1091,7 +1091,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-media"
|
name = "craft-media"
|
||||||
version = "0.1.0"
|
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 = [
|
dependencies = [
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
@@ -1099,7 +1099,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-ui"
|
name = "craft-ui"
|
||||||
version = "0.1.0"
|
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 = [
|
dependencies = [
|
||||||
"craft-fonts-build",
|
"craft-fonts-build",
|
||||||
"craft-i18n",
|
"craft-i18n",
|
||||||
@@ -1120,7 +1120,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-update"
|
name = "craft-update"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -1534,7 +1534,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3245,7 +3245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3881,7 +3881,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3938,7 +3938,7 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4840,10 +4840,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.5",
|
"rustix 1.1.5",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5736,7 +5736,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+4
-4
@@ -50,15 +50,15 @@ zip = { version = "9", default-features = false }
|
|||||||
cpal = "0.18"
|
cpal = "0.18"
|
||||||
|
|
||||||
# Which file paths stored in a session may be opened.
|
# 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.
|
# 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-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: app-specific pretend versions apply to startup and manual checks.
|
||||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||||
|
|
||||||
# ui
|
# 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" }
|
egui = { version = "0.37" }
|
||||||
eframe = { version = "0.37", default-features = false }
|
eframe = { version = "0.37", default-features = false }
|
||||||
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
|
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
|
||||||
|
|||||||
+6
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
|
**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
|
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
|
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
|
## 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:** 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-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.
|
- **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 (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-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 (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
|
- **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
|
||||||
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
|
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,
|
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.
|
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
|
- **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 |
|
| 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: side panels in dock columns; readiness estimates unchanged |
|
||||||
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; 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 |
|
| 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 | 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 | 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 | 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-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list |
|
||||||
| 2026-10-06 | minor | Alpha and parity estimates |
|
| 2026-10-06 | minor | Alpha and parity estimates |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SoundCraft app icon (placeholder)
|
# SoundCraft app icon (placeholder)
|
||||||
|
|
||||||
**A nightingale singing**, as an engraving-style head-and-shoulders portrait in the Crafting Apps'
|
**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
|
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.
|
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
|
# 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
|
# 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.
|
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||||
#
|
#
|
||||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
# Copy this block, replacing your-github-username (every field is optional):
|
||||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
|
||||||
#
|
|
||||||
# Or copy this block, replacing your-github-username (every field is optional):
|
|
||||||
#
|
#
|
||||||
# [people.your-github-username]
|
# [people.your-github-username]
|
||||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||||
@@ -68,3 +65,7 @@ sync_github_name = true
|
|||||||
|
|
||||||
[people.dylanmtaylor]
|
[people.dylanmtaylor]
|
||||||
sync_github_name = true
|
sync_github_name = true
|
||||||
|
|
||||||
|
[people.kk376]
|
||||||
|
real_name = "Kushagra Kumar"
|
||||||
|
sync_github_name = true
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ serde.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
log.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
|
# 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]`.
|
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//! values are the Audio Unit's own (plain) values, ranges and units, so no mapping is needed.
|
//! 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.
|
//! 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
|
//! `unsafe` is denied crate-wide and allowed only in the private `ffi` module (hand-written
|
||||||
//! AudioToolbox / CoreFoundation declarations). The public API is safe, returns
|
//! AudioToolbox / CoreFoundation declarations). The public API is safe, returns
|
||||||
//! `Result`/`Option` and never panics. Limits of in-process hosting: an Audio Unit that itself
|
//! `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).
|
// 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);
|
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)) {
|
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 {
|
let used = match kind {
|
||||||
b"desc" if size == 32 => {
|
b"desc" if size == 32 => {
|
||||||
lists_sizes = be_u32(b, body + 16) == Some(0);
|
lists_sizes = be_u32(b, body + 16) == Some(0);
|
||||||
@@ -28,12 +28,12 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
|
|||||||
described = true;
|
described = true;
|
||||||
Some(32)
|
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"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"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,
|
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)) {
|
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,
|
Some(next) if next < b.len() => pos = next,
|
||||||
@@ -85,3 +85,14 @@ fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
|
|||||||
}
|
}
|
||||||
None
|
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 })
|
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.
|
/// Whether a vendor string or comment in a (little-endian) Vorbis comment block claims more than
|
||||||
/// The vendor string is skipped unread.
|
/// the block holds.
|
||||||
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
|
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 at = (body + 4).saturating_add(vendor as usize);
|
||||||
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
|
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))
|
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ pub struct Peaks {
|
|||||||
struct Block {
|
struct Block {
|
||||||
min: f32,
|
min: f32,
|
||||||
max: f32,
|
max: f32,
|
||||||
squares: f32,
|
squares: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Block {
|
impl Block {
|
||||||
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
|
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
|
||||||
|
|
||||||
fn point(s: f32) -> Block {
|
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 {
|
fn of(samples: &[f32]) -> Block {
|
||||||
@@ -146,7 +146,7 @@ impl Peaks {
|
|||||||
.map(|w| {
|
.map(|w| {
|
||||||
w.map(|(a, b)| {
|
w.map(|(a, b)| {
|
||||||
let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len()));
|
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()
|
.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));
|
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]
|
#[test]
|
||||||
fn rms_spans_match_the_samples() {
|
fn rms_spans_match_the_samples() {
|
||||||
let x = signal(20_000);
|
let x = signal(20_000);
|
||||||
|
|||||||
@@ -503,6 +503,7 @@ pub(crate) fn header(
|
|||||||
) -> Result<Vec<u8>> {
|
) -> Result<Vec<u8>> {
|
||||||
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
|
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 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 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 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 };
|
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(&(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(&(channels as u16).to_le_bytes());
|
||||||
fmt.extend_from_slice(&sample_rate.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(&block_align.to_le_bytes());
|
||||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||||
if extensible {
|
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());
|
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
|
||||||
Ok(head)
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ fn packets_longer_than_any_codec_uses_are_refused() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
|
||||||
#[test]
|
#[test]
|
||||||
fn flac_metadata_fields_beyond_their_block_are_refused() {
|
fn flac_metadata_fields_beyond_their_block_are_refused() {
|
||||||
let with_block = |kind: u8, body: &[u8]| {
|
let with_block = |kind: u8, body: &[u8]| {
|
||||||
@@ -376,7 +377,8 @@ fn flac_metadata_fields_beyond_their_block_are_refused() {
|
|||||||
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
|
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);
|
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();
|
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")));
|
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!(r.call("session.inspect", json!({})).is_ok());
|
||||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
|
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"
|
clap-sys = "0.5"
|
||||||
libloading = "0.9"
|
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
|
# `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]`.
|
# FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! plugins exactly like the built-in ones. Plugin ids are `clap:<CLAP plugin id>`; parameter ids
|
//! 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`].
|
//! 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
|
//! 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
|
//! `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.
|
//! (segfault) takes the process down with it; we cannot sandbox native code.
|
||||||
|
|||||||
@@ -127,8 +127,11 @@ pub fn remove_dc(ch: &mut [Vec<f32>]) {
|
|||||||
if c.is_empty() {
|
if c.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mean = c.iter().filter(|v| v.is_finite()).map(|&v| f64::from(v)).sum::<f64>() / c.len() as f64;
|
let (sum, n) = c.iter().filter(|v| v.is_finite()).fold((0.0f64, 0usize), |(s, n), &v| (s + f64::from(v), n + 1));
|
||||||
let m = mean as f32;
|
if n == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let m = (sum / n as f64) as f32;
|
||||||
c.iter_mut().for_each(|s| *s -= m);
|
c.iter_mut().for_each(|s| *s -= m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -889,4 +892,14 @@ mod tests {
|
|||||||
assert_eq!(ch[0][50], 1.0);
|
assert_eq!(ch[0][50], 1.0);
|
||||||
fade(&mut ch, 1000, 1000, FadeShape::SCurve);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")))
|
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> {
|
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 en = position_param(e, cmd, p, "end")?;
|
||||||
let len = position_param(e, cmd, p, "length")?;
|
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) {
|
match (st, en, len) {
|
||||||
(Some(a), Some(b), _) => Ok(Range::new(a, b)),
|
(Some(a), Some(b), _) => Ok(Range::new(a, b)),
|
||||||
(Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))),
|
(Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))),
|
||||||
|
|||||||
@@ -196,8 +196,14 @@ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||||||
fn effects_set(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 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 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)> =
|
let vals: Vec<(String, f64)> = params
|
||||||
params.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))).collect();
|
.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();
|
let s = e.session_mut();
|
||||||
for c in &ids {
|
for c in &ids {
|
||||||
for (k, v) in &vals {
|
for (k, v) in &vals {
|
||||||
@@ -316,6 +322,13 @@ mod tests {
|
|||||||
(Engine::new(s), t, ids)
|
(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]
|
#[test]
|
||||||
fn unloop_removes_copies() {
|
fn unloop_removes_copies() {
|
||||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100, 100, 0), (200, 100, 0), (300, 100, 50)]);
|
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 s = e.session_mut();
|
||||||
let mut n = 0;
|
let mut n = 0;
|
||||||
if range.is_empty() && !ids.is_empty() {
|
if range.is_empty() && !ids.is_empty() {
|
||||||
for t in &mut s.tracks {
|
n = ops::remove_clips(s, &ids, sh);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.edit.selected_clips.clear();
|
s.edit.selected_clips.clear();
|
||||||
} else {
|
} else {
|
||||||
for t in &tracks {
|
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 r = range_param(e, id, p)?;
|
||||||
let s = e.session();
|
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));
|
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 bpm = f64_or(p, "bpm", f64::NAN);
|
||||||
let s = e.session_mut();
|
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.
|
// 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 after = s.tempo.tempo_at_tick(t1);
|
||||||
let had_end = s.tempo.tempos().iter().any(|x| x.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 crate::demo::demo_engine;
|
||||||
use serde_json::json;
|
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.
|
/// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position.
|
||||||
#[test]
|
#[test]
|
||||||
fn tempo_change_at_position_is_applied() {
|
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> {
|
fn tempo_stretch(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||||
let id = "event.tempo_stretch";
|
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")? {
|
let factor = match position_param(e, id, p, "length")? {
|
||||||
Some(l) if l > 0 => l as f64 / r.len() as f64,
|
Some(l) if l > 0 => l as f64 / r.len() as f64,
|
||||||
_ => f64_or(p, "factor", f64::NAN),
|
_ => 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]
|
#[test]
|
||||||
fn tempo_curves() {
|
fn tempo_curves() {
|
||||||
let mut e = Engine::default();
|
let mut e = Engine::default();
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
vec![
|
vec![
|
||||||
cmd!(noundo "transport.play", "Play", [], Some("Space"), "{from?: position}", always, |e, p| {
|
cmd!(noundo "transport.play", "Play", [], Some("Space"), "{from?: position}", always, |e, p| {
|
||||||
if let Some(at) = position_param(e, "transport.play", p, "from")? {
|
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!({}))
|
Ok(json!({}))
|
||||||
}),
|
}),
|
||||||
cmd!(noundo "transport.stop", "Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Stop); Ok(json!({})) }),
|
cmd!(noundo "transport.stop", "Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Stop); Ok(json!({})) }),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before.
|
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. Cancel Zoom Toggle turns it off
|
||||||
|
//! and keeps the current view.
|
||||||
use super::{CommandSpec, always, f64_or};
|
use super::{CommandSpec, always, f64_or};
|
||||||
use soundcraft_model::{TrackHeight, ZoomToggle};
|
use soundcraft_model::{TrackHeight, ZoomToggle};
|
||||||
use soundcraft_time::MAX_POSITION;
|
use soundcraft_time::MAX_POSITION;
|
||||||
@@ -7,7 +8,16 @@ use crate::{Engine, Result, cmd};
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
pub fn specs() -> Vec<CommandSpec> {
|
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> {
|
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]));
|
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]
|
#[test]
|
||||||
fn hostile_sizes_and_stored_views_stay_in_range() {
|
fn hostile_sizes_and_stored_views_stay_in_range() {
|
||||||
let mut e = engine(json!({"tracks": ["Audio 1"], "start": 0, "end": MAX_POSITION}));
|
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
|
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`.
|
/// 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> {
|
pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
|
||||||
let t = TimeCtx::of(s);
|
let t = TimeCtx::of(s);
|
||||||
|
|||||||
@@ -159,6 +159,40 @@ fn clear_slip_and_shuffle() {
|
|||||||
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 2000, 3000)]);
|
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]
|
#[test]
|
||||||
fn copy_paste_overwrites() {
|
fn copy_paste_overwrites() {
|
||||||
let (mut s, t) = session_with_clip();
|
let (mut s, t) = session_with_clip();
|
||||||
|
|||||||
@@ -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.
|
/// 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;
|
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).
|
/// 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>> {
|
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 smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||||
let s = e.session_mut();
|
|
||||||
if tempo_map {
|
if tempo_map {
|
||||||
// Each meter is placed by walking the ones before it.
|
// Each meter is placed by walking the ones before it.
|
||||||
if smf.meters.len() > MAX_MIDI_METERS {
|
if smf.meters.len() > MAX_MIDI_METERS {
|
||||||
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_METERS} meter changes")));
|
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 {
|
for (tick, bpm) in &smf.tempos {
|
||||||
|
if s.tempo.tempo_at_tick(*tick) == *bpm {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let _ = s.tempo.set_tempo(*tick, *bpm);
|
let _ = s.tempo.set_tempo(*tick, *bpm);
|
||||||
}
|
}
|
||||||
for (tick, n, d) in &smf.meters {
|
for (tick, n, d) in &smf.meters {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::import::add_source;
|
use super::import::add_source;
|
||||||
#[cfg(test)]
|
#[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};
|
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
|
/// 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}");
|
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]
|
#[test]
|
||||||
fn new_sources_take_the_first_free_file_name() {
|
fn new_sources_take_the_first_free_file_name() {
|
||||||
let mut s = Session::default();
|
let mut s = Session::default();
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ pub enum TransportRequest {
|
|||||||
/// Stop the old session's transport without applying stop-time edits to the new document.
|
/// Stop the old session's transport without applying stop-time edits to the new document.
|
||||||
ResetSession,
|
ResetSession,
|
||||||
Play,
|
Play,
|
||||||
|
/// Start playing at this position (or move there if already playing), ignoring the selection.
|
||||||
|
PlayFrom(Samples),
|
||||||
Stop,
|
Stop,
|
||||||
TogglePlay,
|
TogglePlay,
|
||||||
Record,
|
Record,
|
||||||
|
|||||||
@@ -299,6 +299,22 @@ fn shuffle_clear_moves_automation_with_the_clips() {
|
|||||||
assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]);
|
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]
|
#[test]
|
||||||
fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() {
|
fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() {
|
||||||
let (mut e, t, [a, _]) = engine("relative_grid");
|
let (mut e, t, [a, _]) = engine("relative_grid");
|
||||||
|
|||||||
@@ -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)))
|
&& let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0)))
|
||||||
&& !l.points.is_empty()
|
&& !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
|
base
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ impl ClipFx {
|
|||||||
c.set_param(p, v);
|
c.set_param(p, v);
|
||||||
}
|
}
|
||||||
} else if k == "gain" {
|
} else if k == "gain" {
|
||||||
self.gain_db = v;
|
self.gain_db = if v.is_nan() { 0.0 } else { v.clamp(soundcraft_dsp::MIN_DB, crate::CLIP_FX_MAX_GAIN_DB) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ use strip::{DelayLine, MAX_LATENCY};
|
|||||||
use threads::run_strips;
|
use threads::run_strips;
|
||||||
pub use threads::{mark_audio_thread, on_audio_thread};
|
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`].
|
/// Default (stereo) main mix width. The actual width is [`main_channels`].
|
||||||
pub const MAIN_CHANNELS: usize = 2;
|
pub const MAIN_CHANNELS: usize = 2;
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,15 @@ fn sends_feed_aux_via_bus() {
|
|||||||
assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]);
|
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]
|
#[test]
|
||||||
fn render_bus_is_the_bus_not_the_main_mix() {
|
fn render_bus_is_the_bus_not_the_main_mix() {
|
||||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
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`.
|
/// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`.
|
||||||
pub fn parse(s: &str) -> Option<AutoParam> {
|
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() {
|
match s.as_str() {
|
||||||
"volume" | "vol" => return Some(AutoParam::Volume),
|
"volume" | "vol" => return Some(AutoParam::Volume),
|
||||||
"pan" | "pan1" => return Some(AutoParam::Pan(0)),
|
"pan" | "pan1" => return Some(AutoParam::Pan(0)),
|
||||||
@@ -100,6 +101,8 @@ impl AutoParam {
|
|||||||
}
|
}
|
||||||
if let Some(rest) = s.strip_prefix("plugin:") {
|
if let Some(rest) = s.strip_prefix("plugin:") {
|
||||||
let (slot, param) = rest.split_once(':')?;
|
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() });
|
return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() });
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -158,7 +161,10 @@ impl AutomationLane {
|
|||||||
if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) {
|
if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) {
|
||||||
return a.value;
|
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,
|
(Some(a), _) => a.value,
|
||||||
_ => default,
|
_ => default,
|
||||||
@@ -255,6 +261,26 @@ impl AutomationLane {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn pan_label_max_does_not_overflow() {
|
fn pan_label_max_does_not_overflow() {
|
||||||
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
|
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
|
||||||
|
|||||||
@@ -626,6 +626,8 @@ impl Session {
|
|||||||
}
|
}
|
||||||
for l in &mut t.automation {
|
for l in &mut t.automation {
|
||||||
l.points.retain(|p| p.value.is_finite());
|
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.iter_mut().for_each(|p| bound(&mut p.at));
|
||||||
l.points.sort_by_key(|p| p.at);
|
l.points.sort_by_key(|p| p.at);
|
||||||
}
|
}
|
||||||
@@ -905,6 +907,22 @@ mod tests {
|
|||||||
assert!(s.next_id > t.0);
|
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]
|
#[test]
|
||||||
fn a_loaded_auto_name_scheme_leaves_room_for_its_number() {
|
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() };
|
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) {
|
if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) {
|
||||||
return Err(err());
|
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))
|
Ok(tc.to_samples(rate, sr).saturating_sub(tc_start))
|
||||||
}
|
}
|
||||||
TimeFormat::FeetFrames => {
|
TimeFormat::FeetFrames => {
|
||||||
@@ -213,6 +219,20 @@ mod tests {
|
|||||||
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err());
|
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]
|
#[test]
|
||||||
fn bar_lengths() {
|
fn bar_lengths() {
|
||||||
let (sr, map, rate) = ctx();
|
let (sr, map, rate) = ctx();
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ impl GridValue {
|
|||||||
GridValue::Note { value, dotted, triplet } => {
|
GridValue::Note { value, dotted, triplet } => {
|
||||||
let t0 = map.samples_to_ticks(at, sr);
|
let t0 = map.samples_to_ticks(at, sr);
|
||||||
let dt = Self::note_ticks(value, dotted, triplet, map, t0);
|
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::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1),
|
||||||
GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1),
|
GridValue::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(100).stepped(false), GridValue::Samples(10));
|
||||||
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
|
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snap_at_extreme_position_does_not_overflow() {
|
||||||
|
let sr = SampleRate::new(8000).unwrap();
|
||||||
|
let map = TempoMap::new(1000.0, 4, 4).unwrap();
|
||||||
|
let _ = GridValue::default().snap(i64::MAX, sr, &map, FrameRate::Fps30);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,10 +251,10 @@ impl TempoMap {
|
|||||||
for (i, m) in self.meters.iter().enumerate() {
|
for (i, m) in self.meters.iter().enumerate() {
|
||||||
let next_tick = self.meters.get(i + 1).map(|n| n.tick);
|
let next_tick = self.meters.get(i + 1).map(|n| n.tick);
|
||||||
let tpb = m.ticks_per_bar();
|
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 {
|
match bars_in_seg {
|
||||||
Some(n) if bar >= cur_bar + n => {
|
Some(n) if bar >= cur_bar.saturating_add(n) => {
|
||||||
cur_bar += n;
|
cur_bar = cur_bar.saturating_add(n);
|
||||||
cur_tick = next_tick.unwrap_or(cur_tick);
|
cur_tick = next_tick.unwrap_or(cur_tick);
|
||||||
}
|
}
|
||||||
_ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)),
|
_ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)),
|
||||||
@@ -284,7 +284,7 @@ impl TempoMap {
|
|||||||
if let Some(n) = self.meters.get(i + 1)
|
if let Some(n) = self.meters.get(i + 1)
|
||||||
&& tick >= n.tick
|
&& 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;
|
seg_tick = n.tick;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -323,6 +323,11 @@ impl TempoMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ceiling division for a non-negative `num` and positive `den`, without overflow.
|
||||||
|
fn ceil_div(num: i64, den: i64) -> i64 {
|
||||||
|
num / den + i64::from(num % den != 0)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -337,6 +342,16 @@ mod tests {
|
|||||||
assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 });
|
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]
|
#[test]
|
||||||
fn minimum_bar_saturates_instead_of_overflowing() {
|
fn minimum_bar_saturates_instead_of_overflowing() {
|
||||||
let m = TempoMap::default();
|
let m = TempoMap::default();
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
|
||||||
//! standards/contributors.md). Nothing is read at run time.
|
|
||||||
//!
|
//!
|
||||||
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
|
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml
|
||||||
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
|
//! entry is verified.
|
||||||
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
||||||
//! JSON says the entry was verified (`people_entry`).
|
//! JSON says the entry was verified (`people_entry`).
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ impl SoundApp {
|
|||||||
// Re-selecting a scrolling mode resumes the playhead follow.
|
// Re-selecting a scrolling mode resumes the playhead follow.
|
||||||
self.edit_layout.follow_hold = false;
|
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" {
|
if id == "track.clear_clip_indicators" {
|
||||||
self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false);
|
self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,6 +343,21 @@ impl SoundApp {
|
|||||||
self.play_from_selection();
|
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::Stop | TransportRequest::Pause => self.stop_play(),
|
||||||
TransportRequest::TogglePlay => {
|
TransportRequest::TogglePlay => {
|
||||||
if self.is_playing() {
|
if self.is_playing() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! The About window's credits: every contributor (GitHub login always; display and real names only
|
//! 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
|
//! 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.
|
//! read at run time.
|
||||||
|
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
|
|||||||
@@ -511,7 +511,9 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
|
|||||||
}
|
}
|
||||||
return;
|
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 [
|
for (label, cmd) in [
|
||||||
("Mute/Unmute", "edit.mute_clips"),
|
("Mute/Unmute", "edit.mute_clips"),
|
||||||
("Edit Lock/Unlock", "clip.edit_lock"),
|
("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"),
|
("Send to Back", "clip.send_to_back"),
|
||||||
] {
|
] {
|
||||||
if ui.button(label).clicked() {
|
if ui.button(label).clicked() {
|
||||||
let _ = app.run(cmd, json!({"clips": ids}));
|
let _ = app.run(cmd, target.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ui.separator();
|
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);
|
app.dialogs.open_rename_clip(c, &name);
|
||||||
}
|
}
|
||||||
if ui.button("Delete").clicked() {
|
if ui.button("Delete").clicked() {
|
||||||
let _ = app.run("edit.select", json!({"clips": ids}));
|
let s = app.engine.session();
|
||||||
let _ = app.run("edit.clear", json!({}));
|
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) {
|
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));
|
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 {
|
pub fn rect([x0, y0, x1, y1]: [f32; 4]) -> Rect {
|
||||||
|
|||||||
@@ -75,8 +75,13 @@ pub(super) fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zoom Toggle sized to the track area on screen.
|
/// Zoom Toggle sized to the track area on screen. With Alt held (an Alt-click on the button) it cancels Zoom Toggle
|
||||||
|
/// instead and keeps the current view.
|
||||||
pub fn zoom_toggle(app: &mut SoundApp) {
|
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 [x0, y0, x1, y1] = app.edit_layout.timeline;
|
||||||
let _ = app.run("view.zoom_toggle", json!({"width_px": x1 - x0, "height_px": y1 - y0}));
|
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> {
|
fn harness(n: usize) -> Harness<'static, SoundApp> {
|
||||||
let mut engine = soundcraft_engine::Engine::default();
|
let mut engine = soundcraft_engine::Engine::default();
|
||||||
engine.execute("track.new", &json!({"count": n})).unwrap();
|
engine.execute("track.new", &json!({"count": n})).unwrap();
|
||||||
|
|||||||
@@ -62,6 +62,36 @@ fn new_session_cancels_play_queued_before_the_next_frame() {
|
|||||||
assert_eq!(app.engine.transport.position, 0);
|
assert_eq!(app.engine.transport.position, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn play_from_starts_at_from_regardless_of_the_selection() {
|
||||||
|
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||||
|
app.run("edit.select", json!({"tracks": ["Kick"], "start": 7_632_000, "end": 7_680_000})).unwrap();
|
||||||
|
app.run("transport.play", json!({"from": 4_560_000})).unwrap();
|
||||||
|
app.handle_transport(0.25);
|
||||||
|
assert!(app.is_playing());
|
||||||
|
assert_eq!(app.engine.transport.position, 4_560_000 + 12_000);
|
||||||
|
|
||||||
|
app.run("transport.play", json!({"from": 96_000})).unwrap();
|
||||||
|
app.handle_transport(0.25);
|
||||||
|
assert!(app.is_playing());
|
||||||
|
assert_eq!(app.engine.transport.position, 96_000 + 12_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn play_from_while_looping_drops_the_selection_loop() {
|
||||||
|
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||||
|
app.run("options.loop_playback", json!({"value": true})).unwrap();
|
||||||
|
app.run("edit.select", json!({"tracks": ["Kick"], "start": 96_000, "end": 144_000})).unwrap();
|
||||||
|
app.run("transport.play", json!({})).unwrap();
|
||||||
|
app.handle_transport(0.25);
|
||||||
|
assert!(app.is_playing());
|
||||||
|
app.run("transport.play", json!({"from": 480_000})).unwrap();
|
||||||
|
app.handle_transport(0.25);
|
||||||
|
app.handle_transport(0.25);
|
||||||
|
assert!(app.is_playing());
|
||||||
|
assert_eq!(app.engine.transport.position, 480_000 + 24_000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn record_with_nothing_armed_says_what_to_do() {
|
fn record_with_nothing_armed_says_what_to_do() {
|
||||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use crate::SoundApp;
|
use crate::SoundApp;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use craft_update::dialog::{CheckResultDialog, CheckResultResponse};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use craft_update::updater::{Action, Status, Updater};
|
use craft_update::updater::{Action, Status, Updater};
|
||||||
|
|
||||||
@@ -13,8 +15,10 @@ pub(crate) struct Runtime {
|
|||||||
startup_check: bool,
|
startup_check: bool,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
check_requested: bool,
|
check_requested: bool,
|
||||||
pub(crate) manual_window_open: bool,
|
/// The check-result dialog (native) or the web build's notice is showing.
|
||||||
pub(crate) status_message: Option<String>,
|
manual_window_open: bool,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
result_dialog: CheckResultDialog,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Runtime {
|
impl Default for Runtime {
|
||||||
@@ -27,7 +31,8 @@ impl Default for Runtime {
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
check_requested: false,
|
check_requested: false,
|
||||||
manual_window_open: false,
|
manual_window_open: false,
|
||||||
status_message: None,
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
result_dialog: CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,7 +53,6 @@ pub(crate) fn check(app: &mut SoundApp) {
|
|||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
app.updates_rt.manual_window_open = true;
|
app.updates_rt.manual_window_open = true;
|
||||||
app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +62,7 @@ pub(crate) fn tick(app: &mut SoundApp, ctx: &egui::Context) {
|
|||||||
let rt = &mut app.updates_rt;
|
let rt = &mut app.updates_rt;
|
||||||
if std::mem::take(&mut rt.check_requested) {
|
if std::mem::take(&mut rt.check_requested) {
|
||||||
rt.updater.check_now(ctx);
|
rt.updater.check_now(ctx);
|
||||||
|
rt.manual_window_open = true;
|
||||||
} else if rt.startup_check {
|
} else if rt.startup_check {
|
||||||
rt.updater.check_at_start(ctx);
|
rt.updater.check_at_start(ctx);
|
||||||
}
|
}
|
||||||
@@ -77,46 +82,36 @@ pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
|||||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||||
Action::None => {}
|
Action::None => {}
|
||||||
}
|
}
|
||||||
// The window answers Help ▸ Check for Updates, so it follows the updater's status.
|
// Help ▸ Check for Updates shows its outcome in the shared result dialog until the user closes it.
|
||||||
let rt = &mut app.updates_rt;
|
let rt = &mut app.updates_rt;
|
||||||
(rt.manual_window_open, rt.status_message) = match rt.updater.status() {
|
if rt.manual_window_open {
|
||||||
Status::Idle => (false, None),
|
match rt.result_dialog.show(ctx, rt.updater.status()) {
|
||||||
Status::Checking => (true, None),
|
CheckResultResponse::Close => rt.manual_window_open = false,
|
||||||
Status::UpToDate => (true, Some(format!("SoundCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))),
|
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
|
||||||
Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))),
|
CheckResultResponse::None => {}
|
||||||
};
|
}
|
||||||
|
// A found update goes to the startup prompt, which takes over from the dialog.
|
||||||
|
if matches!(rt.updater.status(), Status::Idle) {
|
||||||
|
rt.manual_window_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Closing the dialog dismisses the outcome it showed.
|
||||||
|
if !rt.manual_window_open {
|
||||||
|
rt.updater.dismiss_status();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
if app.updates_rt.manual_window_open {
|
if app.updates_rt.manual_window_open {
|
||||||
let mut open = true;
|
let mut open = true;
|
||||||
let mut close = false;
|
|
||||||
egui::Window::new("Check for Updates")
|
egui::Window::new("Check for Updates")
|
||||||
.open(&mut open)
|
.open(&mut open)
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
.collapsible(false)
|
.collapsible(false)
|
||||||
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
|
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
if let Some(msg) = &app.updates_rt.status_message {
|
ui.label("Update check is not supported in the web browser.");
|
||||||
ui.label(msg);
|
|
||||||
} else {
|
|
||||||
ui.label("Checking for updates…");
|
|
||||||
}
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
||||||
if ui.button("Close").clicked() {
|
|
||||||
close = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
if !open || close {
|
app.updates_rt.manual_window_open = open;
|
||||||
app.updates_rt.manual_window_open = false;
|
|
||||||
app.updates_rt.status_message = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Closing the window dismisses the outcome it showed.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
if !app.updates_rt.manual_window_open {
|
|
||||||
app.updates_rt.updater.dismiss_status();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Regression: the clip context menu must stay open while the pointer moves onto it, so its
|
//! Regression: the clip context menu must stay open while the pointer moves onto it, so its
|
||||||
//! Delete item can be used. It used to vanish once the pointer left the lane, because the lane
|
//! Delete item can be used. It used to vanish once the pointer left the lane, because the lane
|
||||||
//! returned early (no hover position) before the menu was drawn.
|
//! returned early (no hover position) before the menu was drawn. A right-click on a clip of the
|
||||||
|
//! edit selection acts on the whole selection (#1191).
|
||||||
|
|
||||||
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
|
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
|
||||||
use egui_kittest::Harness;
|
use egui_kittest::Harness;
|
||||||
@@ -24,15 +25,16 @@ fn press(h: &mut Harness<'_, SoundApp>, x: f32, y: f32, button: PointerButton) {
|
|||||||
s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE });
|
s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE });
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize {
|
fn clip_count(h: &Harness<'_, SoundApp>, track: &str) -> usize {
|
||||||
let s = h.state().engine.session();
|
let s = h.state().engine.session();
|
||||||
s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len())
|
s.tracks.iter().find(|t| t.name == track).map_or(0, |t| t.clips().len())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete
|
fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize {
|
||||||
/// removes one Kick clip.
|
clip_count(h, "Kick")
|
||||||
#[test]
|
}
|
||||||
fn clip_menu_stays_open_while_moving_onto_delete() {
|
|
||||||
|
fn demo() -> Harness<'static, SoundApp> {
|
||||||
let engine = soundcraft_engine::demo::demo_engine();
|
let engine = soundcraft_engine::demo::demo_engine();
|
||||||
let app = SoundApp::new(engine, None, Services::default());
|
let app = SoundApp::new(engine, None, Services::default());
|
||||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
|
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
|
||||||
@@ -46,26 +48,123 @@ fn clip_menu_stays_open_while_moving_onto_delete() {
|
|||||||
for _ in 0..6 {
|
for _ in 0..6 {
|
||||||
step(&mut h);
|
step(&mut h);
|
||||||
}
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects the whole session on `tracks`, as a drag across their clips does.
|
||||||
|
fn select_all_of(h: &mut Harness<'_, SoundApp>, tracks: &[&str]) {
|
||||||
|
h.state_mut().engine.execute("edit.select", &serde_json::json!({"tracks": tracks, "start": 0, "end": 48_000 * 600})).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Right-clicks the Kick clip and clicks `item` in its menu, moving onto the item first as a real pointer would.
|
||||||
|
fn kick_menu(h: &mut Harness<'_, SoundApp>, item: &str) {
|
||||||
|
press(h, 880.0, 240.0, PointerButton::Secondary);
|
||||||
|
for _ in 0..10 {
|
||||||
|
step(h);
|
||||||
|
}
|
||||||
|
// Ask the newly laid-out menu where the item is instead of pinning coordinates that change with egui's item metrics.
|
||||||
|
let at = h.get_by_label(item).rect().center();
|
||||||
|
h.state_mut().synthetic.push(Event::PointerMoved(at));
|
||||||
|
for _ in 0..10 {
|
||||||
|
step(h);
|
||||||
|
}
|
||||||
|
press(h, at.x, at.y, PointerButton::Primary);
|
||||||
|
for _ in 0..15 {
|
||||||
|
step(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete
|
||||||
|
/// removes one Kick clip.
|
||||||
|
#[test]
|
||||||
|
fn clip_menu_stays_open_while_moving_onto_delete() {
|
||||||
|
let mut h = demo();
|
||||||
let before = kick_clip_count(&h);
|
let before = kick_clip_count(&h);
|
||||||
assert!(before > 0, "the demo session should have a recorded Kick clip");
|
assert!(before > 0, "the demo session should have a recorded Kick clip");
|
||||||
|
|
||||||
// Right-click the Kick clip, then move the pointer into the menu and down to Delete.
|
kick_menu(&mut h, "Delete");
|
||||||
press(&mut h, 880.0, 240.0, PointerButton::Secondary);
|
|
||||||
for _ in 0..10 {
|
|
||||||
step(&mut h);
|
|
||||||
}
|
|
||||||
// Move (no click) onto the menu's Delete item, as a real pointer would, so the lane loses
|
|
||||||
// its hover. Ask the newly laid-out menu where the item is instead of pinning coordinates
|
|
||||||
// that change with egui's item metrics.
|
|
||||||
let delete = h.get_by_label("Delete").rect().center();
|
|
||||||
h.state_mut().synthetic.push(Event::PointerMoved(delete));
|
|
||||||
for _ in 0..10 {
|
|
||||||
step(&mut h);
|
|
||||||
}
|
|
||||||
press(&mut h, delete.x, delete.y, PointerButton::Primary);
|
|
||||||
for _ in 0..15 {
|
|
||||||
step(&mut h);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip");
|
assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete on a clip of a drag selection clears the whole selection, as Edit > Clear does, in one undo step (#1191).
|
||||||
|
#[test]
|
||||||
|
fn delete_on_a_selected_clip_clears_the_whole_selection() {
|
||||||
|
let mut h = demo();
|
||||||
|
let (kick, snare) = (clip_count(&h, "Kick"), clip_count(&h, "Snare"));
|
||||||
|
assert!(snare > 1, "the demo session should have several Snare clips");
|
||||||
|
select_all_of(&mut h, &["Kick", "Snare"]);
|
||||||
|
|
||||||
|
kick_menu(&mut h, "Delete");
|
||||||
|
|
||||||
|
assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (0, 0), "every selected clip should be cleared");
|
||||||
|
h.state_mut().engine.execute("edit.undo", &serde_json::json!({})).unwrap();
|
||||||
|
assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (kick, snare), "one undo should bring them all back");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clip outside the selection is still deleted on its own, and the selection's clips stay.
|
||||||
|
#[test]
|
||||||
|
fn delete_on_a_clip_outside_the_selection_removes_only_that_clip() {
|
||||||
|
let mut h = demo();
|
||||||
|
let (kick, snare) = (clip_count(&h, "Kick"), clip_count(&h, "Snare"));
|
||||||
|
select_all_of(&mut h, &["Snare"]);
|
||||||
|
|
||||||
|
kick_menu(&mut h, "Delete");
|
||||||
|
|
||||||
|
assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (kick - 1, snare));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With clips selected one by one (Grabber shift-click), Delete removes exactly those clips, not the other clips
|
||||||
|
/// their span covers.
|
||||||
|
#[test]
|
||||||
|
fn delete_on_selected_clips_keeps_the_unselected_clips_between_them() {
|
||||||
|
let mut h = demo();
|
||||||
|
let ids = |h: &Harness<'_, SoundApp>, track: &str| -> Vec<u64> {
|
||||||
|
let s = h.state().engine.session();
|
||||||
|
s.tracks.iter().filter(|t| t.name == track).flat_map(|t| t.clips()).map(|c| c.id.0).collect()
|
||||||
|
};
|
||||||
|
let (kick, snare) = (ids(&h, "Kick"), ids(&h, "Snare"));
|
||||||
|
let last_snare = *snare.last().unwrap();
|
||||||
|
let clips: Vec<u64> = kick.iter().copied().chain([last_snare]).collect();
|
||||||
|
h.state_mut().engine.execute("edit.select", &serde_json::json!({ "clips": clips })).unwrap();
|
||||||
|
|
||||||
|
kick_menu(&mut h, "Delete");
|
||||||
|
|
||||||
|
assert!(ids(&h, "Kick").is_empty(), "the selected Kick clips should be removed");
|
||||||
|
let left: Vec<u64> = snare.iter().copied().filter(|id| *id != last_snare).collect();
|
||||||
|
assert_eq!(ids(&h, "Snare"), left, "only the selected Snare clip should be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selected clips are deleted even when the track selection was emptied after selecting them (Edit > Selection >
|
||||||
|
/// Remove Edit from Top keeps the clip selection).
|
||||||
|
#[test]
|
||||||
|
fn delete_on_selected_clips_works_without_a_track_selection() {
|
||||||
|
let mut h = demo();
|
||||||
|
let kick = clip_count(&h, "Kick");
|
||||||
|
let clips: Vec<u64> = {
|
||||||
|
let s = h.state().engine.session();
|
||||||
|
s.tracks.iter().filter(|t| t.name == "Kick").flat_map(|t| t.clips()).map(|c| c.id.0).collect()
|
||||||
|
};
|
||||||
|
let e = &mut h.state_mut().engine;
|
||||||
|
e.execute("edit.select", &serde_json::json!({ "clips": clips })).unwrap();
|
||||||
|
e.execute("edit.remove_selection_top", &serde_json::json!({})).unwrap();
|
||||||
|
assert!(e.session().edit.selected_tracks.is_empty() && !e.session().edit.selected_clips.is_empty());
|
||||||
|
|
||||||
|
kick_menu(&mut h, "Delete");
|
||||||
|
|
||||||
|
assert_eq!(clip_count(&h, "Kick"), kick - clips.len(), "the selected Kick clips should be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The menu's other entries act on the whole selection too.
|
||||||
|
#[test]
|
||||||
|
fn mute_on_a_selected_clip_mutes_every_selected_clip() {
|
||||||
|
let mut h = demo();
|
||||||
|
select_all_of(&mut h, &["Kick", "Snare"]);
|
||||||
|
|
||||||
|
kick_menu(&mut h, "Mute/Unmute");
|
||||||
|
|
||||||
|
let s = h.state().engine.session();
|
||||||
|
let clips: Vec<_> = s.tracks.iter().filter(|t| t.name == "Kick" || t.name == "Snare").flat_map(|t| t.clips()).collect();
|
||||||
|
assert!(clips.len() > 2);
|
||||||
|
assert!(clips.iter().all(|c| c.muted), "every selected clip should be muted");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use egui::os::OperatingSystem;
|
use egui::os::OperatingSystem;
|
||||||
use egui::{Key, vec2};
|
use egui::{Key, Modifiers, vec2};
|
||||||
use egui_kittest::{Harness, kittest::Queryable};
|
use egui_kittest::{Harness, kittest::Queryable};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use soundcraft_ui_egui::{Services, SoundApp};
|
use soundcraft_ui_egui::{Services, SoundApp};
|
||||||
@@ -41,3 +41,46 @@ fn e_fits_the_selection_to_the_edit_window_and_the_button_goes_back() {
|
|||||||
assert_eq!(view(h.state()), before, "{os:?}");
|
assert_eq!(view(h.state()), before, "{os:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alt_shift_e_and_an_alt_click_cancel_zoom_toggle_and_keep_the_zoomed_view() {
|
||||||
|
for os in [OperatingSystem::Mac, OperatingSystem::Windows, OperatingSystem::Nix] {
|
||||||
|
for alt_click in [false, true] {
|
||||||
|
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||||
|
let bass = app.engine.session().track_by_name("Bass").unwrap().id.0;
|
||||||
|
app.engine.execute("edit.select", &json!({"tracks": [bass], "start": 96_000, "end": 192_000})).unwrap();
|
||||||
|
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_os(os).build_ui_state(
|
||||||
|
|ui, app: &mut SoundApp| {
|
||||||
|
app.logic(&ui.ctx().clone());
|
||||||
|
app.ui(ui);
|
||||||
|
},
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
h.run_steps(5);
|
||||||
|
let view = |a: &SoundApp| (a.engine.session().edit.zoom, a.edit_layout.rows.clone(), a.edit_layout.scroll_y);
|
||||||
|
let before = view(h.state());
|
||||||
|
h.get_by_label("Zoom Toggle (E)").click();
|
||||||
|
h.run_steps(1);
|
||||||
|
let zoomed = view(h.state());
|
||||||
|
assert_ne!(zoomed, before, "{os:?}");
|
||||||
|
|
||||||
|
if alt_click {
|
||||||
|
h.get_by_label("Zoom Toggle (E)").click_modifiers(Modifiers::ALT);
|
||||||
|
} else {
|
||||||
|
h.key_press_modifiers(Modifiers::ALT | Modifiers::SHIFT, Key::E);
|
||||||
|
}
|
||||||
|
h.run_steps(2);
|
||||||
|
let how = if alt_click { "Alt-click" } else { "Alt+Shift+E" };
|
||||||
|
assert_eq!(h.state().engine.session().edit.zoom_toggle, None, "{os:?} {how}");
|
||||||
|
assert_eq!(view(h.state()), zoomed, "{os:?} {how} keeps the zoomed view");
|
||||||
|
|
||||||
|
// The next toggle zooms in from here and goes back here, not to the view before the cancel.
|
||||||
|
h.get_by_label("Zoom Toggle (E)").click();
|
||||||
|
h.run_steps(1);
|
||||||
|
assert!(h.state().engine.session().edit.zoom_toggle.is_some(), "{os:?} {how}");
|
||||||
|
h.get_by_label("Zoom Toggle (E)").click();
|
||||||
|
h.run_steps(1);
|
||||||
|
assert_eq!(view(h.state()), zoomed, "{os:?} {how}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ impl Dpb {
|
|||||||
let mut ref_idx = 0usize;
|
let mut ref_idx = 0usize;
|
||||||
for m in &sh.ref_pic_list_mod[l] {
|
for m in &sh.ref_pic_list_mod[l] {
|
||||||
ensure!(ref_idx < n, "too many ref_pic_list_modification entries");
|
ensure!(ref_idx < n, "too many ref_pic_list_modification entries");
|
||||||
|
ensure!(m.idc >= 2 || i64::from(m.value) < i64::from(max_pic_num), "abs_diff_pic_num_minus1 out of range");
|
||||||
list.push(None); // temporarily one longer
|
list.push(None); // temporarily one longer
|
||||||
let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc {
|
let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc {
|
||||||
0 | 1 => {
|
0 | 1 => {
|
||||||
@@ -262,9 +263,9 @@ impl Dpb {
|
|||||||
for m in mmcos {
|
for m in mmcos {
|
||||||
match m.op {
|
match m.op {
|
||||||
1 => {
|
1 => {
|
||||||
let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1);
|
let pn = i64::from(curr_pic_num) - (i64::from(m.difference_of_pic_nums_minus1) + 1);
|
||||||
for e in &mut self.entries {
|
for e in &mut self.entries {
|
||||||
if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn {
|
if e.mark == RefMark::Short && i64::from(Self::frame_num_wrap(e, cur_frame_num, max_frame_num)) == pn {
|
||||||
e.mark = RefMark::Unused;
|
e.mark = RefMark::Unused;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,14 +278,14 @@ impl Dpb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
3 => {
|
3 => {
|
||||||
let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1);
|
let pn = i64::from(curr_pic_num) - (i64::from(m.difference_of_pic_nums_minus1) + 1);
|
||||||
for e in &mut self.entries {
|
for e in &mut self.entries {
|
||||||
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
|
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
|
||||||
e.mark = RefMark::Unused;
|
e.mark = RefMark::Unused;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for e in &mut self.entries {
|
for e in &mut self.entries {
|
||||||
if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn {
|
if e.mark == RefMark::Short && i64::from(Self::frame_num_wrap(e, cur_frame_num, max_frame_num)) == pn {
|
||||||
e.mark = RefMark::Long;
|
e.mark = RefMark::Long;
|
||||||
e.long_term_frame_idx = m.long_term_frame_idx;
|
e.long_term_frame_idx = m.long_term_frame_idx;
|
||||||
}
|
}
|
||||||
@@ -418,3 +419,67 @@ impl Default for Dpb {
|
|||||||
Self::new()
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::h264::slice::RefPicListMod;
|
||||||
|
|
||||||
|
fn mmco(op: u32, diff: u32) -> Mmco {
|
||||||
|
Mmco { op, difference_of_pic_nums_minus1: diff, long_term_pic_num: 0, long_term_frame_idx: 0, max_long_term_frame_idx_plus1: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mmco_huge_difference_does_not_panic() {
|
||||||
|
for op in [1, 3] {
|
||||||
|
for diff in [0x7FFF_FFFF, 0x8000_0000, u32::MAX] {
|
||||||
|
let mut dpb = Dpb::new();
|
||||||
|
dpb.apply_mmcos(&[mmco(op, diff)], 5, 16, &mut None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ref_list_mod_huge_value_does_not_panic() {
|
||||||
|
for idc in [0, 1] {
|
||||||
|
for value in [0x7FFF_FFFF, 0x8000_0000, u32::MAX] {
|
||||||
|
let sh = SliceHeader {
|
||||||
|
nal_ref_idc: 1,
|
||||||
|
nal_unit_type: 1,
|
||||||
|
idr: false,
|
||||||
|
first_mb_in_slice: 0,
|
||||||
|
slice_type_raw: 0,
|
||||||
|
slice_type: SliceType::P,
|
||||||
|
pps_id: 0,
|
||||||
|
colour_plane_id: 0,
|
||||||
|
frame_num: 5,
|
||||||
|
field_pic: false,
|
||||||
|
bottom_field: false,
|
||||||
|
idr_pic_id: 0,
|
||||||
|
pic_order_cnt_lsb: 0,
|
||||||
|
delta_pic_order_cnt_bottom: 0,
|
||||||
|
delta_pic_order_cnt: [0; 2],
|
||||||
|
redundant_pic_cnt: 0,
|
||||||
|
direct_spatial_mv_pred: false,
|
||||||
|
num_ref_idx_active: [1, 0],
|
||||||
|
ref_pic_list_mod: [vec![RefPicListMod { idc, value }], Vec::new()],
|
||||||
|
pred_weight_table: None,
|
||||||
|
no_output_of_prior_pics: false,
|
||||||
|
long_term_reference: false,
|
||||||
|
adaptive_ref_pic_marking: false,
|
||||||
|
mmcos: Vec::new(),
|
||||||
|
cabac_init_idc: 0,
|
||||||
|
slice_qp_delta: 0,
|
||||||
|
sp_for_switch: false,
|
||||||
|
slice_qs_delta: 0,
|
||||||
|
disable_deblocking_filter_idc: 0,
|
||||||
|
slice_alpha_c0_offset_div2: 0,
|
||||||
|
slice_beta_offset_div2: 0,
|
||||||
|
slice_group_change_cycle: 0,
|
||||||
|
header_bits: 0,
|
||||||
|
};
|
||||||
|
assert!(Dpb::new().build_ref_lists(&sh, 0, 16).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -467,14 +467,14 @@ impl PocState {
|
|||||||
let max_lsb = 1i32 << sps.log2_max_poc_lsb;
|
let max_lsb = 1i32 << sps.log2_max_poc_lsb;
|
||||||
let lsb = sh.pic_order_cnt_lsb as i32;
|
let lsb = sh.pic_order_cnt_lsb as i32;
|
||||||
let msb = if lsb < prev_lsb && prev_lsb - lsb >= max_lsb / 2 {
|
let msb = if lsb < prev_lsb && prev_lsb - lsb >= max_lsb / 2 {
|
||||||
prev_msb + max_lsb
|
prev_msb.saturating_add(max_lsb)
|
||||||
} else if lsb > prev_lsb && lsb - prev_lsb > max_lsb / 2 {
|
} else if lsb > prev_lsb && lsb - prev_lsb > max_lsb / 2 {
|
||||||
prev_msb - max_lsb
|
prev_msb.saturating_sub(max_lsb)
|
||||||
} else {
|
} else {
|
||||||
prev_msb
|
prev_msb
|
||||||
};
|
};
|
||||||
let top = msb + lsb;
|
let top = msb.saturating_add(lsb);
|
||||||
let bottom = top + sh.delta_pic_order_cnt_bottom;
|
let bottom = top.saturating_add(sh.delta_pic_order_cnt_bottom);
|
||||||
Poc { top, bottom, msb, frame_num_offset: 0 }
|
Poc { top, bottom, msb, frame_num_offset: 0 }
|
||||||
}
|
}
|
||||||
1 => {
|
1 => {
|
||||||
@@ -638,4 +638,17 @@ mod tests {
|
|||||||
fn huge_slice_qp_delta_is_invalid() {
|
fn huge_slice_qp_delta_is_invalid() {
|
||||||
assert!(matches!(parse_override(0, 0, 0, i32::MAX), Err(crate::h264::Error::Invalid(_))));
|
assert!(matches!(parse_override(0, 0, 0, i32::MAX), Err(crate::h264::Error::Invalid(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poc0_huge_bottom_delta_does_not_overflow() {
|
||||||
|
let (mut sps, _) = sps_pps();
|
||||||
|
sps.pic_order_cnt_type = 0;
|
||||||
|
sps.log2_max_poc_lsb = 4;
|
||||||
|
let mut sh = parse_override(0, 0, 0, 0).unwrap();
|
||||||
|
sh.pic_order_cnt_lsb = 1;
|
||||||
|
sh.delta_pic_order_cnt_bottom = i32::MAX;
|
||||||
|
let poc = PocState::default().compute(&sh, &sps);
|
||||||
|
assert_eq!(poc.top, 1);
|
||||||
|
assert_eq!(poc.bottom, i32::MAX);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ harness = false
|
|||||||
name = "module_exit_shutdown"
|
name = "module_exit_shutdown"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-clap-host: this crate
|
# An isolated unsafe helper crate, like soundcraft-clap-host: this crate
|
||||||
# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere
|
# 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]`.
|
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! [`PluginInfo::params`]. Values are plain (display) values; the host converts to and from the
|
//! [`PluginInfo::params`]. Values are plain (display) values; the host converts to and from the
|
||||||
//! VST3 normalized 0..1 values. Stepped parameters become toggles or choices.
|
//! VST3 normalized 0..1 values. Stepped parameters become toggles or choices.
|
||||||
//!
|
//!
|
||||||
//! Like `soundcraft-clap-host`, this is an isolated `unsafe` crate (craftrules never-crash):
|
//! Like `soundcraft-clap-host`, this is an isolated `unsafe` crate:
|
||||||
//! `unsafe` is denied crate-wide and allowed only in the private `ffi` module. The public API is
|
//! `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
|
//! 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.
|
//! itself crashes (segfault) takes the process down with it; we cannot sandbox native code.
|
||||||
|
|||||||
+4
-21
@@ -1,16 +1,14 @@
|
|||||||
# Contributors in the About window
|
# Contributors in the About window
|
||||||
|
|
||||||
**About ▸ Contributors** credits everyone who contributed to SoundCraft, and **About ▸ Models** credits
|
**About ▸ Contributors** credits everyone who contributed to SoundCraft, and **About ▸ Models** credits
|
||||||
the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard
|
the AI models named in `Co-Authored-By` trailers.
|
||||||
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
|
|
||||||
this page is the local copy of the decision.
|
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
||||||
disk or the network at run time; the web build has them too):
|
disk or the network at run time; the web build has them too):
|
||||||
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
||||||
username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit.
|
username. Never hand-edit.
|
||||||
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
||||||
themselves. **Each contributor adds only their own entry**, in their own PR.
|
themselves. **Each contributor adds only their own entry**, in their own PR.
|
||||||
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
||||||
@@ -36,20 +34,5 @@ display_name = "Nickname"
|
|||||||
sync_github_name = true # use the name on your public GitHub profile as display name
|
sync_github_name = true # use the name on your public GitHub profile as display name
|
||||||
```
|
```
|
||||||
|
|
||||||
or, with craftrules checked out next to `craft-apps/`:
|
Names show for entries committed by their own user (or by a maintainer with a `consent_source`).
|
||||||
|
Delete your entry to remove your names. Git author names and emails are never recorded.
|
||||||
```sh
|
|
||||||
python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name
|
|
||||||
```
|
|
||||||
|
|
||||||
Names show after the next refresh: the script checks that each entry was committed by its own
|
|
||||||
user (or by a maintainer with a `consent_source`). Delete your entry to remove your names.
|
|
||||||
|
|
||||||
## Refreshing (maintainers)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh
|
|
||||||
python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only
|
|
||||||
```
|
|
||||||
|
|
||||||
Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.
|
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
|
|||||||
|
|
||||||
- **Missing:** opening or saving `.ptx`/`.ptf`, AAF and OMF import and export (#40), Media
|
- **Missing:** opening or saving `.ptx`/`.ptf`, AAF and OMF import and export (#40), Media
|
||||||
Composer–compatible sessions. Our session format is `.scraft` JSON.
|
Composer–compatible sessions. Our session format is `.scraft` JSON.
|
||||||
- **Impact:** the standard's main-format bar for beta. A Pro Tools user cannot bring a project in
|
- **Impact:** a main-format requirement for beta. A Pro Tools user cannot bring a project in
|
||||||
or hand one back; post-production (picture editors deliver AAF) is blocked.
|
or hand one back; post-production (picture editors deliver AAF) is blocked.
|
||||||
- **Estimate:** AAF/OMF import + export ≈ 50–90 h (FilmCraft already writes AAF/OMF; we can learn
|
- **Estimate:** AAF/OMF import + export ≈ 50–90 h (FilmCraft already writes AAF/OMF; we can learn
|
||||||
from it but not share code). `.ptx` is proprietary and undocumented: owner decision on whether
|
from it but not share code). `.ptx` is proprietary and undocumented: owner decision on whether
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Release playbook
|
|
||||||
|
|
||||||
The release playbook shared by every crafting app lives in craftrules:
|
|
||||||
[`../craftrules/release/playbook.md`](../../craftrules/release/playbook.md)
|
|
||||||
(`storytold/craftrules`). It is the canonical copy; don't duplicate it here.
|
|
||||||
|
|
||||||
SoundCraft's implementation (`.github/workflows/release.yml`, `packaging/`,
|
|
||||||
`cargo xtask version`) was copied from DesignCraft/PhotoCraft and renamed. SoundCraft-specific
|
|
||||||
details are in [releasing.md](releasing.md).
|
|
||||||
+2
-3
@@ -5,7 +5,7 @@ signed installers for macOS, Windows, Linux, FreeBSD and the web, then creates o
|
|||||||
**draft** GitHub Release named `SoundCraft v<version>`. Nobody sees a draft until a maintainer
|
**draft** GitHub Release named `SoundCraft v<version>`. Nobody sees a draft until a maintainer
|
||||||
publishes it.
|
publishes it.
|
||||||
|
|
||||||
This is SoundCraft's implementation of the shared [release playbook](release-playbook.md).
|
This file describes how SoundCraft is released.
|
||||||
User-facing names say **SoundCraft**. Files, binaries and ids stay lowercase
|
User-facing names say **SoundCraft**. Files, binaries and ids stay lowercase
|
||||||
(`soundcraft-<version>-<platform>-<arch>.<ext>`, `ai.storyteller.soundcraft`).
|
(`soundcraft-<version>-<platform>-<arch>.<ext>`, `ai.storyteller.soundcraft`).
|
||||||
|
|
||||||
@@ -208,8 +208,7 @@ are written with `umask 077` and deleted in `always()`/`finally` steps.
|
|||||||
| `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` | service principal for Azure Trusted Signing |
|
| `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` | service principal for Azure Trusted Signing |
|
||||||
| `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_CERT_PROFILE` | Trusted Signing endpoint, account and profile |
|
| `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_CERT_PROFILE` | Trusted Signing endpoint, account and profile |
|
||||||
|
|
||||||
`GITHUB_TOKEN` creates the release. Only the final job gets `contents: write`. Provisioning the
|
`GITHUB_TOKEN` creates the release. Only the final job gets `contents: write`.
|
||||||
environment, ruleset and secrets for a new repo: craftrules `release/signing-setup.md`.
|
|
||||||
|
|
||||||
## Icons
|
## Icons
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -18,8 +18,7 @@ Hours are Opus 5.5 agent-hours.
|
|||||||
## Alpha gate
|
## Alpha gate
|
||||||
|
|
||||||
The core workflows a Pro Tools professional does every day, checked end to end on macOS (the
|
The core workflows a Pro Tools professional does every day, checked end to end on macOS (the
|
||||||
main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are the alpha checklist
|
main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are the alpha checklist.
|
||||||
(craftrules `standards/progress-docs.md`, Stages).
|
|
||||||
|
|
||||||
| Workflow | Works end to end? | Evidence | Hours to pass |
|
| Workflow | Works end to end? | Evidence | Hours to pass |
|
||||||
|---|---|---|---:|
|
|---|---|---|---:|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ every command is reachable from the CLI, the JSON control channel and MCP.
|
|||||||
|
|
||||||
## Mainstream practitioner and essentials user
|
## Mainstream practitioner and essentials user
|
||||||
|
|
||||||
Both use the method in craftrules `standards/progress-docs.md`. Each multiplies a weighted depth
|
Both use one method. Each multiplies a weighted depth
|
||||||
average by written-down discounts. Depths come from the [Feature areas](#feature-areas) table and
|
average by written-down discounts. Depths come from the [Feature areas](#feature-areas) table and
|
||||||
the evidence there; issue numbers are `storytold/soundcraft` GitHub issues.
|
the evidence there; issue numbers are `storytold/soundcraft` GitHub issues.
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ immersive delivery, video, notation, and languages other than English.
|
|||||||
| File exchange with Pro Tools users | ×0.85 | Engineers trade sessions, not stems. No `.ptx`, AAF or OMF (#40); WAV/BWF stems and MIDI files do work, so the factor isn't lower |
|
| File exchange with Pro Tools users | ×0.85 | Engineers trade sessions, not stems. No `.ptx`, AAF or OMF (#40); WAV/BWF stems and MIDI files do work, so the factor isn't lower |
|
||||||
| **Result** | 50.6 × 0.90 × 0.92 × 0.85 = **35.6 % ≈ 36 %** | |
|
| **Result** | 50.6 × 0.90 × 0.92 × 0.85 = **35.6 % ≈ 36 %** | |
|
||||||
|
|
||||||
This comes out *below* the full number (~40 %), which the standard says is unusual. The
|
This comes out *below* the full number (~40 %), which is unusual. The
|
||||||
mainstream engineer's weekly core is interface recording and MIDI overdubs, exactly the
|
mainstream engineer's weekly core is interface recording and MIDI overdubs, exactly the
|
||||||
alpha-gate failures. The full number gets partial credit from areas this user doesn't weight:
|
alpha-gate failures. The full number gets partial credit from areas this user doesn't weight:
|
||||||
editing breadth, five platforms built, agent control.
|
editing breadth, five platforms built, agent control.
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
# UI parity
|
# UI parity
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Tracks and Groups and Clips in dock columns) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Cancel Zoom Toggle) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||||
|
|
||||||
How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
|
How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
|
||||||
windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
|
windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
|
||||||
@@ -21,6 +21,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
|
|||||||
| Live waveform fade preview | ✅ | Waveform amplitude updates during fade-length and midpoint-curvature drags, sharing the curve's temporary envelope. Mono/stereo, waveform styles, zoom and clip gain remain effective; release preserves the displayed waveform with one undo step and cached peaks |
|
| Live waveform fade preview | ✅ | Waveform amplitude updates during fade-length and midpoint-curvature drags, sharing the curve's temporary envelope. Mono/stereo, waveform styles, zoom and clip gain remain effective; release preserves the displayed waveform with one undo step and cached peaks |
|
||||||
| Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present |
|
| Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present |
|
||||||
| Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples |
|
| Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples |
|
||||||
|
| Zoom Toggle | 🟡 | E (Commands focus) and the button zoom to the edit selection with Fit To Window heights and back; Alt+Shift+E and Alt-click cancel it and keep the view. The Zoom Toggle preferences (Vertical Zoom, Horizontal Zoom, Track Height, Track View, Separate Grid Settings When Zoomed In, Zoom Toggle Follows Edit Selection, Remove Range Selection After Zooming In) are not present |
|
||||||
| Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial |
|
| Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial |
|
||||||
| Link timeline/edit, link track/edit, Tab to transient, insertion follows playback | ✅ | |
|
| Link timeline/edit, link track/edit, Tab to transient, insertion follows playback | ✅ | |
|
||||||
| Mirrored MIDI, MIDI merge, layered editing | 🟡 | Stored and partly honoured |
|
| Mirrored MIDI, MIDI merge, layered editing | 🟡 | Stored and partly honoured |
|
||||||
@@ -69,6 +70,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-25 | minor | Zoom Toggle row: Alt+Shift+E and Alt-click cancel Zoom Toggle and keep the view. Readiness estimate unchanged |
|
||||||
| 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged |
|
| 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged |
|
||||||
| 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged |
|
| 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged |
|
||||||
| 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
|
| 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
|
||||||
|
|||||||
Reference in New Issue
Block a user