diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52b76762..983c62d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ jobs: # The shared workflow creates the draft release, which needs write access to contents. permissions: contents: write + packages: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index eae50d1a..6514b657 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: soundcraft app-dir: SoundCraft diff --git a/AGENTS.md b/AGENTS.md index 76338a2b..065e8313 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,9 @@ SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). -Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft-apps folder, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. - ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed). -2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them. +2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them. 3. Work autonomously; don't stop to ask unless a decision is genuinely the user's. ## ⚠️ Assets: the absolute rule @@ -22,7 +20,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l `), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names. ## Never crash -People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** - No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API. - The same exception, same rules, for `soundcraft-vst3-host` (`crates/vst3-host`), the isolated unsafe helper crate for VST3 plugin FFI (COM vtables via the `vst3` bindings, module loading, host-side COM objects). @@ -67,7 +65,7 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc `cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit. ## Release -Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`. +Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/releasing.md`. ## Roadmap `ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry. @@ -81,11 +79,13 @@ Push to the `release` branch to build signed installers for macOS (universal DMG username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 00927097..dc168fa4 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -39,14 +39,14 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts | Path | Author | Source | License | |---|---|---|---| -| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | -| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | +| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | +| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | | `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 | | `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 | | `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 | diff --git a/CLAUDE.md b/CLAUDE.md index b5c917a3..ac02bdd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,10 @@ Read `AGENTS.md`: it is the entry point for agents in this repo. + +New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit +`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes +before modules or other crate items; crate-level `//!` documentation may precede them. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index 5efeb6a2..a46e8ce2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1064,17 +1064,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1097,7 +1097,7 @@ dependencies = [ [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "thiserror 2.0.21", ] @@ -1105,7 +1105,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1126,13 +1126,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -4296,7 +4297,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.13.1" +version = "0.16.0" dependencies = [ "chrono", "clap", @@ -4320,7 +4321,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.13.1" +version = "0.16.0" dependencies = [ "log", "serde", @@ -4330,7 +4331,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.13.1" +version = "0.16.0" dependencies = [ "log", "proptest", @@ -4342,7 +4343,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.13.1" +version = "0.16.0" dependencies = [ "clap", "log", @@ -4355,7 +4356,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.13.1" +version = "0.16.0" dependencies = [ "clap-sys", "libloading 0.9.0", @@ -4368,7 +4369,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.13.1" +version = "0.16.0" dependencies = [ "clap", "clap_complete", @@ -4385,7 +4386,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.13.1" +version = "0.16.0" dependencies = [ "log", "proptest", @@ -4396,7 +4397,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.13.1" +version = "0.16.0" dependencies = [ "craft-docpath", "log", @@ -4419,7 +4420,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.13.1" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -4429,7 +4430,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.13.1" +version = "0.16.0" dependencies = [ "log", "rayon", @@ -4445,7 +4446,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.13.1" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -4458,7 +4459,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.13.1" +version = "0.16.0" dependencies = [ "cpal", "log", @@ -4473,7 +4474,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.13.1" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -4483,7 +4484,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.13.1" +version = "0.16.0" dependencies = [ "craft-ui", "craft-update", @@ -4512,7 +4513,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.13.1" +version = "0.16.0" dependencies = [ "craft-media", "image", @@ -4524,7 +4525,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.13.1" +version = "0.16.0" dependencies = [ "libloading 0.9.0", "log", @@ -4540,7 +4541,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.13.1" +version = "0.16.0" dependencies = [ "eframe", "egui", @@ -6188,7 +6189,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.13.1" +version = "0.16.0" dependencies = [ "serde_json", ] @@ -6408,9 +6409,9 @@ dependencies = [ [[package]] name = "zip" -version = "9.1.1" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "crc32fast", "flate2", diff --git a/Cargo.toml b/Cargo.toml index d2a9c4fc..5bb43523 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.13.1" +version = "0.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" @@ -50,14 +50,15 @@ zip = { version = "9", default-features = false } cpal = "0.18" # Which file paths stored in a session may be opened. -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } # Video containers and bitstreams, shared with the other craft apps. -craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] } egui = { version = "0.37" } eframe = { version = "0.37", default-features = false } # Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent). @@ -70,9 +71,11 @@ image = { version = "0.25", default-features = false, features = ["png"] } unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" +too_many_arguments = "deny" type_complexity = "allow" [profile.dev.package."*"] diff --git a/README.md b/README.md index 1024eba0..eff0ad64 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,16 @@ Useful shortcuts: Space play/stop, ⌘= Mix/Edi separate, ⌘D duplicate, Enter new marker, ⌘⇧N new tracks. **Setup › Keyboard Shortcuts** lists them all. +In **New Tracks**, each row has its own count, format, type, Samples/Ticks timebase and name. +Use **+** to add a row below or **−** to remove it; **Create** makes all rows in order, with one +Undo step (up to 128 tracks per creation). The last row stays available. The dropdown widths +fit their longest option and remain fixed when the selection changes. + +On macOS, **⌘←/→** changes the active row's format, **⌘↑/↓** its type (also with **⌃**), +and **⌥⌘↑/↓** its timebase. **⇧⌃⌘↓/↑** adds/removes the active row, also with **⌥**; +**⇧⌘↓/↑** works too. Windows/Linux use **Ctrl** for **⌘**. The active row follows the +field you click. A new row starts with its count focused and selected; Enter creates and Escape cancels. + ### Logs The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the @@ -340,7 +350,15 @@ stand on its own. | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | -| | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. diff --git a/ROADMAP.md b/ROADMAP.md index 8ff79f11..0edff22b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows) -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change. - **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change. - **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change. - **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change. @@ -156,8 +157,8 @@ mutated and truncated movies. Field reports from the first public week are track - **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change. - **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change. - **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change. -- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard - progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ +- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the progress docs + set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours, tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session. - **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media @@ -176,6 +177,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged | | 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged | | 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged | @@ -204,6 +206,6 @@ mutated and truncated movies. Field reports from the first public week are track | 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h | | 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers | | 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added | -| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md | +| 2026-10-10 | major | Progress docs restructured: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md | | 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list | | 2026-10-06 | minor | Alpha and parity estimates | diff --git a/apps/soundcraft-cli/src/args.rs b/apps/soundcraft-cli/src/args.rs index 40351779..6b583992 100644 --- a/apps/soundcraft-cli/src/args.rs +++ b/apps/soundcraft-cli/src/args.rs @@ -154,7 +154,8 @@ pub(crate) struct RunArgs { pub(crate) struct ScriptArgs { /// The script to run pub(crate) file: PathBuf, - /// Report a failing line and go on with the next + /// Continue after command errors and finish requested output, then exit nonzero if any command failed. + /// Malformed JSON stops immediately #[arg(long)] pub(crate) keep_going: bool, #[command(flatten)] diff --git a/apps/soundcraft-cli/src/error.rs b/apps/soundcraft-cli/src/error.rs index a048694c..ebf5af70 100644 --- a/apps/soundcraft-cli/src/error.rs +++ b/apps/soundcraft-cli/src/error.rs @@ -20,6 +20,8 @@ pub(crate) enum CliError { ScriptJson { path: PathBuf, line: usize, source: serde_json::Error }, #[error("{}:{line}: {id}: {source}", path.display())] ScriptLine { path: PathBuf, line: usize, id: String, source: EngineError }, + #[error("script finished with command errors")] + ScriptFailed, #[error("{}: {source}", path.display())] File { path: PathBuf, source: std::io::Error }, #[error("{}: {source}", path.display())] diff --git a/apps/soundcraft-cli/src/session.rs b/apps/soundcraft-cli/src/session.rs index 20e17b1a..3f09cf64 100644 --- a/apps/soundcraft-cli/src/session.rs +++ b/apps/soundcraft-cli/src/session.rs @@ -75,6 +75,7 @@ pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write let text = std::fs::read_to_string(path).map_err(|source| CliError::File { path: path.clone(), source })?; let mut e = load_engine(&args.engine, err)?; let keep = args.keep_going; + let mut failed = false; for (n, line) in text.lines().enumerate() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { @@ -90,10 +91,12 @@ pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write return Err(failure); } outln!(err, "{failure}"); + failed = true; } } } - finish(&mut e, &args.engine, out) + finish(&mut e, &args.engine, out)?; + if failed { Err(CliError::ScriptFailed) } else { Ok(()) } } #[cfg(test)] diff --git a/apps/soundcraft-cli/tests/it/behaviour.rs b/apps/soundcraft-cli/tests/it/behaviour.rs index ce0076d1..69bdffa9 100644 --- a/apps/soundcraft-cli/tests/it/behaviour.rs +++ b/apps/soundcraft-cli/tests/it/behaviour.rs @@ -238,13 +238,13 @@ fn script_line_errors_stop_unless_keep_going() { let dir = sandbox("script-errors"); std::fs::write(dir.join("bad.txt"), "nosuch.cmd {}\ntrack.new {}\n").unwrap(); std::fs::write(dir.join("badjson.txt"), "track.new {x\n").unwrap(); - // A stopping line error carries the `error:` prefix; a --keep-going one is a bare line. + // Kept-going line errors stay bare; a final error summary supplies the failing status. let stop = invoke(&dir, &["script", "bad.txt"]); assert_eq!((stop.code, stop.stdout.as_str()), (1, "")); assert_eq!(stop.stderr, "error: bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n"); let keep = invoke(&dir, &["script", "bad.txt", "--keep-going"]); - assert_eq!((keep.code, keep.stdout.as_str()), (0, "track.new: {\"tracks\":[1]}\n")); - assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n"); + assert_eq!((keep.code, keep.stdout.as_str()), (1, "track.new: {\"tracks\":[1]}\n")); + assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\nerror: script finished with command errors\n"); assert_fails(&invoke(&dir, &["script", "badjson.txt"]), "badjson.txt:1: bad JSON: key must be a string at line 1 column 2"); } @@ -515,10 +515,9 @@ fn mcp_roots_with_connect_are_a_usage_error() { ); } -/// A name that isn't valid Unicode works wherever the tool itself reads or writes the file -/// (`convert`); a session or bounce the engine opens or writes by text name is refused (exit 1), -/// naming the file. Run in-process: the names need no shell. -#[cfg(unix)] +/// Native file paths round-trip on filesystems that accept non-Unicode names. macOS rejects +/// these names with EILSEQ before the tool can write them. Run in-process: no shell is needed. +#[cfg(all(unix, not(target_os = "macos")))] #[test] fn non_unicode_file_names() { use std::ffi::OsString; @@ -543,8 +542,26 @@ fn non_unicode_file_names() { std::fs::rename(dir.join("s.scraft"), latin1("scraft")).unwrap(); let (code, err) = run(vec!["info".into(), latin1("scraft").into()]); assert_eq!(code, 0, "{err}"); - let (code, err) = run(vec!["run".into(), "--demo".into(), "--bounce".into(), latin1("wav").into()]); - assert_eq!(code, 1); - assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Engine output paths enter JSON, so invalid Unicode must be refused even on a filesystem +/// that cannot create such a file (including macOS). +#[cfg(unix)] +#[test] +fn non_unicode_session_outputs_are_rejected() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let dir = sandbox("nonunicode-outputs"); + for (flag, name) in [("--bounce", b"caf\xe9.wav".as_slice()), ("--save", b"caf\xe9.scraft".as_slice())] { + let path = dir.join(OsString::from_vec(name.to_vec())); + let (mut out, mut err) = (Vec::new(), Vec::new()); + let code = soundcraft_cli::run(vec!["run".into(), "--demo".into(), flag.into(), path.into_os_string()], &mut out, &mut err); + let err = String::from_utf8_lossy(&err); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}"); + assert!(out.is_empty()); + } let _ = std::fs::remove_dir_all(&dir); } diff --git a/apps/soundcraft-cli/tests/it/main.rs b/apps/soundcraft-cli/tests/it/main.rs index f1c44788..a5a22774 100644 --- a/apps/soundcraft-cli/tests/it/main.rs +++ b/apps/soundcraft-cli/tests/it/main.rs @@ -9,3 +9,4 @@ mod closed_stdout; mod man_and_completions; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod run; +mod script_status; diff --git a/apps/soundcraft-cli/tests/it/script_status.rs b/apps/soundcraft-cli/tests/it/script_status.rs new file mode 100644 index 00000000..4321b8b6 --- /dev/null +++ b/apps/soundcraft-cli/tests/it/script_status.rs @@ -0,0 +1,90 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct Scratch(PathBuf); + +impl Scratch { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let sequence = NEXT.fetch_add(1, Ordering::Relaxed); + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("soundcraft-script-status-{}-{stamp}-{sequence}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn invoke(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).current_dir(dir).env("SOUNDCRAFT_NO_PREFS", "1").args(args).output().unwrap() +} + +#[test] +fn keep_going_runs_later_commands_and_finishes_but_reports_failure() { + let dir = Scratch::new(); + std::fs::write( + dir.0.join("audit.txt"), + "audit.deliberately_invalid {}\ntrack.new {\"name\":\"After failure\",\"count\":1,\"format\":\"stereo\"}\n", + ) + .unwrap(); + let stop = invoke(&dir.0, &["script", "audit.txt", "--save", "stopped.scraft"]); + assert_eq!(stop.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&stop.stderr).contains("audit.deliberately_invalid")); + assert!(!String::from_utf8_lossy(&stop.stdout).contains("track.new:")); + assert!(!dir.0.join("stopped.scraft").exists()); + + let keep = invoke( + &dir.0, + &["script", "audit.txt", "--keep-going", "--bounce", "after.wav", "--start", "0", "--end", "0.01", "--save", "after.scraft", "--inspect"], + ); + assert_eq!(keep.status.code(), Some(1), "{}", String::from_utf8_lossy(&keep.stderr)); + assert!(String::from_utf8_lossy(&keep.stderr).contains("audit.deliberately_invalid")); + let stdout = String::from_utf8_lossy(&keep.stdout); + assert!(stdout.contains("track.new:") && stdout.contains("After failure"), "{stdout}"); + assert!(dir.0.join("after.wav").is_file()); + let info = invoke(&dir.0, &["info", "after.scraft"]); + assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr)); + assert!(String::from_utf8_lossy(&info.stdout).contains("After failure")); +} + +#[test] +fn successful_script_and_parsing_or_finish_failures_keep_their_status() { + let dir = Scratch::new(); + std::fs::write(dir.0.join("good.txt"), "track.new {\"name\":\"Good\",\"count\":1,\"format\":\"stereo\"}\n").unwrap(); + let success = invoke(&dir.0, &["script", "good.txt", "--keep-going"]); + assert!(success.status.success(), "{}", String::from_utf8_lossy(&success.stderr)); + + std::fs::write(dir.0.join("bad.txt"), "track.new {bad\ntrack.new {\"name\":\"Too late\"}\n").unwrap(); + let malformed = invoke(&dir.0, &["script", "bad.txt", "--keep-going", "--save", "malformed.scraft"]); + assert_eq!(malformed.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&malformed.stderr).contains("bad JSON")); + assert!(!String::from_utf8_lossy(&malformed.stdout).contains("track.new:")); + assert!(!dir.0.join("malformed.scraft").exists()); + + std::fs::write(dir.0.join("parent"), "not a directory").unwrap(); + for (option, path) in [("--save", "parent/out.scraft"), ("--bounce", "parent/out.wav")] { + let fail = invoke(&dir.0, &["script", "good.txt", "--keep-going", option, path]); + assert_eq!(fail.status.code(), Some(1), "{option}: {}", String::from_utf8_lossy(&fail.stderr)); + assert!(String::from_utf8_lossy(&fail.stderr).contains("parent")); + } + + std::fs::write(dir.0.join("engine.txt"), "session.save {}\ntrack.new {\"name\":\"After engine failure\",\"count\":1}\n").unwrap(); + let fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "engine.scraft"]); + assert_eq!(fail.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&fail.stderr).contains("session.save")); + assert!(String::from_utf8_lossy(&fail.stdout).contains("track.new:")); + assert!(dir.0.join("engine.scraft").is_file()); + + let both_fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "parent/out.scraft"]); + assert_eq!(both_fail.status.code(), Some(1)); + let errors = String::from_utf8_lossy(&both_fail.stderr); + assert!(errors.contains("session.save") && errors.contains("parent"), "{errors}"); + assert!(!errors.contains("script finished with command errors"), "{errors}"); +} diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 8e54a5bd..ddf57e05 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -185,7 +185,7 @@ fn app_icon() -> Option { fn main() -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&soundcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // First, so every start-up record (and the engine's panic hook, installed with the first diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 12cea9c2..196473c9 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -1,7 +1,7 @@ # SoundCraft app icon (placeholder) **A nightingale singing**, as an engraving-style head-and-shoulders portrait in the Crafting Apps' -owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colour field, the bird +owl-template framing: a full-bleed colour field, the bird in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off the bottom and right edges, three song arcs leaving the bill. diff --git a/clippy.toml b/clippy.toml index 6244ea8c..2a016f24 100644 --- a/clippy.toml +++ b/clippy.toml @@ -6,3 +6,4 @@ allow-indexing-slicing-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 diff --git a/contributors/people.toml b/contributors/people.toml index ab7b9de3..06ae81ce 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" @@ -61,3 +58,14 @@ display_name = "Matt Smith-Burlage" [people.ProdByBuddha] sync_github_name = true + +[people.luigiPinna] +real_name = "Luigi Pinna" +sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true + +[people.kk376] +real_name = "Kushagra Kumar" +sync_github_name = true diff --git a/crates/au-host/Cargo.toml b/crates/au-host/Cargo.toml index af793163..4e190104 100644 --- a/crates/au-host/Cargo.toml +++ b/crates/au-host/Cargo.toml @@ -13,14 +13,16 @@ serde.workspace = true thiserror.workspace = true log.workspace = true -# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-vst3-host: this crate +# An isolated unsafe helper crate, like soundcraft-vst3-host: this crate # opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere # and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`. [lints.rust] unsafe_code = "deny" [lints.clippy] +cognitive_complexity = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" +too_many_arguments = "deny" type_complexity = "allow" diff --git a/crates/au-host/src/lib.rs b/crates/au-host/src/lib.rs index 7aa3b2eb..cbb569fb 100644 --- a/crates/au-host/src/lib.rs +++ b/crates/au-host/src/lib.rs @@ -9,7 +9,7 @@ //! values are the Audio Unit's own (plain) values, ranges and units, so no mapping is needed. //! Boolean parameters become toggles, indexed ones starting at 0 become choices. //! -//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate (craftrules never-crash): +//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate: //! `unsafe` is denied crate-wide and allowed only in the private `ffi` module (hand-written //! AudioToolbox / CoreFoundation declarations). The public API is safe, returns //! `Result`/`Option` and never panics. Limits of in-process hosting: an Audio Unit that itself diff --git a/crates/audio-io/src/caf.rs b/crates/audio-io/src/caf.rs index 94ae7f04..9d3d9ccb 100644 --- a/crates/audio-io/src/caf.rs +++ b/crates/audio-io/src/caf.rs @@ -20,7 +20,7 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> { // Whether the packet table lists each packet's size and frame count (variable per packet). let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false); while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) { - let (body, size) = (pos + 12, size as i64); + let body = pos + 12; let used = match kind { b"desc" if size == 32 => { lists_sizes = be_u32(b, body + 16) == Some(0); @@ -28,12 +28,12 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> { described = true; Some(32) } - b"data" if size == -1 => Some(4), + b"data" if size == u64::MAX => Some(4), b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20), b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?, - b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")), + b"kuki" if size > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")), b"desc" | b"pakt" | b"chan" => None, - _ => u64::try_from(size).ok(), + _ => Some(size), }; match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) { Some(next) if next < b.len() => pos = next, @@ -85,3 +85,14 @@ fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> { } None } + +#[cfg(test)] +mod tests { + #[test] + fn rejects_cookie_size_above_signed_range() { + let mut bytes = b"caff\0\x01\0\0kuki".to_vec(); + bytes.extend_from_slice(&(1u64 << 63).to_be_bytes()); + + assert!(super::check(&bytes, 0).is_err()); + } +} diff --git a/crates/audio-io/src/flac.rs b/crates/audio-io/src/flac.rs index 1aef23b9..8f489047 100644 --- a/crates/audio-io/src/flac.rs +++ b/crates/audio-io/src/flac.rs @@ -511,10 +511,13 @@ pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option { Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None }) } -/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds. -/// The vendor string is skipped unread. +/// Whether a vendor string or comment in a (little-endian) Vorbis comment block claims more than +/// the block holds. fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool { - let Some(vendor) = le_u32(b, body) else { return false }; + let Some(vendor) = le_u32(b, body).filter(|_| body.saturating_add(4) <= end) else { return false }; + if vendor as usize > end - (body + 4) { + return true; + } let at = (body + 4).saturating_add(vendor as usize); let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false }; overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize)) diff --git a/crates/audio-io/src/peaks.rs b/crates/audio-io/src/peaks.rs index 9553665c..6cd8f321 100644 --- a/crates/audio-io/src/peaks.rs +++ b/crates/audio-io/src/peaks.rs @@ -24,14 +24,14 @@ pub struct Peaks { struct Block { min: f32, max: f32, - squares: f32, + squares: f64, } impl Block { const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 }; fn point(s: f32) -> Block { - Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } } + Block { min: s, max: s, squares: if s.is_finite() { f64::from(s) * f64::from(s) } else { 0.0 } } } fn of(samples: &[f32]) -> Block { @@ -146,7 +146,7 @@ impl Peaks { .map(|w| { w.map(|(a, b)| { let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len())); - (self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt() + (self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f64).sqrt() as f32 }) }) .collect() @@ -215,6 +215,13 @@ mod tests { assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite)); } + #[test] + fn finite_large_samples_keep_finite_rms() { + let samples = vec![1e20_f32; BLOCK]; + let peaks = Peaks::build(&samples); + assert_eq!(peaks.rms_spans(&samples, 0.0, BLOCK as f64, 0.0, 1), vec![Some(1e20)]); + } + #[test] fn rms_spans_match_the_samples() { let x = signal(20_000); diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index ddf3d719..ca96b964 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -580,13 +580,46 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf Ok((info, AudioBuffer { sample_rate, channels: planar })) } +/// Frames symphonia's AAC decoder yields for every packet; it supports no other frame length. +const AAC_FRAMES_PER_PACKET: u64 = 1024; + +/// Whether `track` of `reader` is AAC in MP4. The MP4 reader reports the stts total as the length, +/// but the decoder yields a whole frame per packet (sample durations are not applied), so a last +/// sample whose duration differs from a frame makes the two disagree. +fn is_mp4_aac(reader: &dyn FormatReader, track_id: u32) -> bool { + reader.format_info().short_name == "isomp4" + && reader.tracks().iter().find(|t| t.id == track_id).and_then(audio_params).is_some_and(|p| p.codec == CODEC_ID_AAC) +} + +/// The decoded length of AAC in MP4: count the track's packets without decoding them. +fn mp4_aac_frames(reader: &mut dyn FormatReader, track_id: u32) -> Result { + let mut frames: u64 = 0; + loop { + match reader.next_packet() { + Ok(Some(packet)) if packet.track_id == track_id => frames = frames.saturating_add(AAC_FRAMES_PER_PACKET), + Ok(Some(_)) => {} + Ok(None) => break, + Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(SymError::ResetRequired) => break, + Err(e) => { + demux_failure(frames, e)?; + break; + } + } + } + Ok(frames) +} + pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result { guarded(|| { - let reader = match open(bytes, detected)? { + let mut reader = match open(bytes, detected)? { Opened::Reader(reader) => reader, Opened::Empty(info) => return Ok(info), }; - let (_, info) = info_of(reader.as_ref(), detected)?; + let (track_id, mut info) = info_of(reader.as_ref(), detected)?; + if info.frames > 0 && info.channels > 0 && is_mp4_aac(reader.as_ref(), track_id) { + info.frames = mp4_aac_frames(reader.as_mut(), track_id)?; + } if info.frames > 0 && info.channels > 0 { return Ok(info); } diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index ee93cd46..9edbb3d8 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -342,7 +342,10 @@ pub(crate) fn read_channel_mask(b: &[u8]) -> Option { fn put_fixed_str(out: &mut Vec, s: &str, len: usize) { let bytes = s.as_bytes(); - let n = bytes.len().min(len); + let mut n = bytes.len().min(len); + while !s.is_char_boundary(n) { + n -= 1; + } out.extend_from_slice(bytes.get(..n).unwrap_or_default()); out.resize(out.len() + (len - n), 0); } @@ -500,6 +503,7 @@ pub(crate) fn header( ) -> Result> { let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?; let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?; + let byte_rate = sample_rate.checked_mul(u32::from(block_align)).ok_or_else(|| AudioError::Encode("WAV byte rate overflow".into()))?; let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels)); let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM }; @@ -508,7 +512,7 @@ pub(crate) fn header( fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes()); fmt.extend_from_slice(&(channels as u16).to_le_bytes()); fmt.extend_from_slice(&sample_rate.to_le_bytes()); - fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes()); + fmt.extend_from_slice(&byte_rate.to_le_bytes()); fmt.extend_from_slice(&block_align.to_le_bytes()); fmt.extend_from_slice(&bits.to_le_bytes()); if extensible { @@ -557,3 +561,14 @@ pub(crate) fn header( head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes()); Ok(head) } + +#[cfg(test)] +mod tests { + use super::header; + + #[test] + fn rejects_byte_rate_overflow() { + let result = header(2, 1_100_000_000, (16, false), None, None, 0, false); + assert!(result.is_err(), "overflowing byte rate was accepted"); + } +} diff --git a/crates/audio-io/tests/it/main.rs b/crates/audio-io/tests/it/main.rs index d71617af..33cc9012 100644 --- a/crates/audio-io/tests/it/main.rs +++ b/crates/audio-io/tests/it/main.rs @@ -4,6 +4,7 @@ mod decode_read_list; mod file_format; mod float_headroom; mod malformed; +mod mp4_length; mod roundtrip; mod stream; mod wav_empty_data; diff --git a/crates/audio-io/tests/it/malformed.rs b/crates/audio-io/tests/it/malformed.rs index 6d00bcdc..fd23f922 100644 --- a/crates/audio-io/tests/it/malformed.rs +++ b/crates/audio-io/tests/it/malformed.rs @@ -110,6 +110,15 @@ fn zero_channels_is_malformed() { assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_)))); } +#[test] +fn aiff_negative_channel_count_is_malformed() { + let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1); + let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap(); + aiff[comm + 8] = 0x80; + aiff[comm + 9] = 0; + assert!(probe(&aiff, None).is_err()); +} + #[test] fn zero_sample_rate_is_malformed() { let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]); @@ -208,6 +217,70 @@ fn caf_sizes_beyond_the_file_are_errors_not_aborts() { assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets"))); } +/// An EBML element: `id`, then `size` as an 8-byte vint (so any size up to 2^56 − 2 fits, and +/// `u64::MAX` writes the reserved "unknown size"), then `body`. +fn ebml(id: &[u8], size: u64, body: &[u8]) -> Vec { + let size = if size == u64::MAX { 0x01FF_FFFF_FFFF_FFFF } else { 0x0100_0000_0000_0000 | size }; + [id, &size.to_be_bytes(), body].concat() +} + +/// The EBML header of a Matroska file. +fn ebml_header() -> Vec { + let doc_type = ebml(&[0x42, 0x82], 8, b"matroska"); + ebml(&[0x1A, 0x45, 0xDF, 0xA3], doc_type.len() as u64, &doc_type) +} + +/// #878: symphonia's Matroska reader takes element sizes from the file. With symphonia 0.5 a size +/// far beyond the file was allocated up front and aborted the process (no `catch_unwind` can catch +/// an allocation failure); since 0.6 such reads grow with the data actually read. Keep it that way +/// for the header, for codec data and for block frames. +#[test] +fn matroska_sizes_beyond_the_file_are_errors_not_aborts() { + let huge = 1u64 << 47; + // The 30-byte file from the report: EBML header and DocType sizes of 2^48 − 1 and 2^47. + let reported = [ + 0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x42, 0x82, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6D, + 0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61, + ]; + let segment = |body: Vec| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat(); + let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat(); + let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info); + // A valid 16-bit PCM track entry's children, followed by `extra`. + let pcm = |extra: &[u8]| { + let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat(); + [ + ebml(&[0xD7], 1, &[1]), + ebml(&[0x73, 0xC5], 1, &[1]), + ebml(&[0x83], 1, &[2]), + ebml(&[0x86], 13, b"A_PCM/INT/LIT"), + ebml(&[0xE1], audio.len() as u64, &audio), + extra.to_vec(), + ] + .concat() + }; + // That track with a CodecPrivate claiming 2^46 bytes, inside parents that claim even more: the + // segment is valid up to that read, which runs out of file. + let track = ebml(&[0xAE], huge, &pcm(&ebml(&[0x63, 0xA2], huge >> 1, &[0; 16]))); + let codec_data = segment([info.clone(), ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)].concat()); + // The plain track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data. + let track = pcm(&[]); + let track = ebml(&[0xAE], track.len() as u64, &track); + let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat()); + let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], track.len() as u64, &track), block].concat()); + let out_of_file = |r: Result<_, AudioError>| matches!(r, Err(AudioError::Malformed(m)) if m.contains("end of file")); + for hint in [None, Some("mkv"), Some("wav")] { + assert!(probe(&reported, hint).is_err(), "{hint:?}"); + assert!(decode(&reported, hint).is_err(), "{hint:?}"); + // Refused at the oversized read itself, not by anything before it. + assert!(out_of_file(probe(&codec_data, hint).map(|_| ())), "{hint:?}: {:?}", probe(&codec_data, hint).err()); + assert!(out_of_file(decode(&codec_data, hint).map(|_| ())), "{hint:?}"); + // The file and its track open, so decoding reaches the block; the block reader stops at the + // end of the file, a best-effort read of what is there. + assert!(probe(&cluster, hint).is_ok(), "{hint:?}: {:?}", probe(&cluster, hint).err()); + assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}"); + } +} + #[test] fn well_formed_caf_still_reads() { let pcm: Vec = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect(); @@ -302,6 +375,7 @@ fn packets_longer_than_any_codec_uses_are_refused() { } } +/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed. #[test] fn flac_metadata_fields_beyond_their_block_are_refused() { let with_block = |kind: u8, body: &[u8]| { @@ -312,7 +386,8 @@ fn flac_metadata_fields_beyond_their_block_are_refused() { let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat(); assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300); let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat(); - for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] { + let vendor_overflow = [&0x7fff_fff0u32.to_le_bytes()[..], &0u32.to_le_bytes()].concat(); + for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment), with_block(4, &vendor_overflow)] { assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block"))); } } diff --git a/crates/audio-io/tests/it/mp4_length.rs b/crates/audio-io/tests/it/mp4_length.rs new file mode 100644 index 00000000..c24bc7ce --- /dev/null +++ b/crates/audio-io/tests/it/mp4_length.rs @@ -0,0 +1,61 @@ +//! The length `probe` reports for AAC in MP4 is the length `decode` produces (issue #1113). + +use soundcraft_audio_io::{decode, probe}; + +fn atom(kind: &[u8; 4], body: &[u8]) -> Vec { + [&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat() +} + +/// A full atom (version 0, no flags) whose body is `fields` as big-endian u32s. +fn full(kind: &[u8; 4], fields: &[u32]) -> Vec { + let body: Vec = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect(); + atom(kind, &body) +} + +/// An MPEG-4 descriptor with a one-byte length. +fn descriptor(tag: u8, body: &[u8]) -> Vec { + [&[tag, body.len() as u8][..], body].concat() +} + +/// A silent AAC-LC raw frame for one channel: an SCE with `max_sfb` 0, then ID_END. +const SILENT_SCE: [u8; 4] = [0x00, 0xC8, 0x00, 0x07]; + +/// A mono 48 kHz AAC-LC MP4 without an edit list: `packets` frames, the last lasting `last_dur` in stts. +fn aac_mp4(packets: u32, last_dur: u32) -> Vec { + // AudioSpecificConfig: AAC-LC, 48 kHz, one channel. + let dsi = descriptor(5, &[0x11, 0x88]); + let dcd = descriptor(4, &[&[0x40, 0x15, 0, 0, 0][..], &[0; 8], &dsi].concat()); + let es = descriptor(3, &[&[0, 1, 0][..], &dcd, &descriptor(6, &[2])].concat()); + let esds = atom(b"esds", &[&[0; 4][..], &es].concat()); + let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + let entry = atom(b"mp4a", &[&base[..], &(48_000u32 << 16).to_be_bytes(), &esds].concat()); + let total = (packets - 1) * 1024 + last_dur; + let moov = |offset: u32| { + let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat()); + let stbl = [ + stsd, + full(b"stts", &[2, packets - 1, 1024, 1, last_dur]), + full(b"stsc", &[1, 1, packets, 1]), + full(b"stsz", &[SILENT_SCE.len() as u32, packets]), + full(b"stco", &[1, offset]), + ]; + let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat(); + let mdia = [full(b"mdhd", &[0, 0, 48_000, total, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)]; + let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat(); + atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, total, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat()) + }; + let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom"); + let offset = (ftyp.len() + moov(0).len() + 8) as u32; + [ftyp, moov(offset), atom(b"mdat", &SILENT_SCE.repeat(packets as usize))].concat() +} + +#[test] +fn aac_in_mp4_probe_length_matches_the_decoded_length() { + // A last sample lasting until the track end (longer than a packet), and a control with a short one. + for last_dur in [1145, 546] { + let file = aac_mp4(10, last_dur); + let (_, buf) = decode(&file, Some("m4a")).unwrap(); + assert_eq!(buf.frames(), 10 * 1024, "decoded frames, last stts duration {last_dur}"); + assert_eq!(probe(&file, Some("m4a")).unwrap().frames, buf.frames() as u64, "probed frames, last stts duration {last_dur}"); + } +} diff --git a/crates/audio-io/tests/it/roundtrip.rs b/crates/audio-io/tests/it/roundtrip.rs index 70042966..77cd2b69 100644 --- a/crates/audio-io/tests/it/roundtrip.rs +++ b/crates/audio-io/tests/it/roundtrip.rs @@ -316,3 +316,13 @@ fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() { let frames = u64::MAX / 4; assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_)))); } + +#[test] +fn bwf_truncation_keeps_utf8_boundaries() { + let src = sine(48_000, 1, 10); + let bwf = BwfInfo { originator: format!("{}é", "a".repeat(31)), description: format!("{}é", "d".repeat(255)), ..BwfInfo::default() }; + let bytes = encode(&src, &EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }).unwrap(); + let out = probe(&bytes, None).unwrap().bwf.unwrap(); + assert_eq!(out.originator, "a".repeat(31)); + assert_eq!(out.description, "d".repeat(255)); +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6955db33..88406158 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -317,4 +317,19 @@ mod tests { assert!(r.call("session.inspect", json!({})).is_ok()); assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2); } + + #[test] + fn remote_returns_an_app_error_unchanged() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() }; + std::thread::spawn(move || { + let Ok((mut sock, _)) = listener.accept() else { return }; + let mut line = String::new(); + let _ = BufReader::new(sock.try_clone().unwrap()).read_line(&mut line); + let _ = writeln!(sock, "{}", json!({"id": 1, "ok": false, "error": "transport.locate: nothing to reset"})); + }); + let mut r = Remote::new(&addr); + let err = r.call("engine.execute", json!({"command": "transport.locate"})).unwrap_err(); + assert_eq!(err, "transport.locate: nothing to reset"); + } } diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index e4ac191a..5b582dfc 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -199,6 +199,9 @@ impl Server { /// Handle one JSON-RPC message; returns the reply line (None for notifications). pub fn handle(&mut self, msg: &Value) -> Option { + if !msg.is_object() { + return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request"}})); + } let id = msg.get("id").cloned(); let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); let params = msg.get("params").cloned().unwrap_or(json!({})); @@ -255,6 +258,9 @@ impl Server { continue; } let reply = match serde_json::from_str::(&line) { + Ok(Value::Array(batch)) if batch.is_empty() => { + Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request: empty batch"}})) + } Ok(Value::Array(batch)) => { let replies: Vec = batch.iter().filter_map(|m| self.handle(m)).collect(); if replies.is_empty() { None } else { Some(Value::Array(replies)) } @@ -270,3 +276,39 @@ impl Server { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Headless; + use soundcraft_engine::Engine; + + fn reply(line: &str) -> String { + let mut server = Server::new(Headless::new(Engine::default())); + let mut out = Vec::new(); + server.serve(line.as_bytes(), &mut out).unwrap(); + String::from_utf8(out).unwrap() + } + + #[test] + fn invalid_requests_get_an_invalid_request_error() { + for input in ["[]", "42", "[1]", "\"x\""] { + let text = reply(input); + assert_eq!(text.lines().count(), 1, "input {input}: reply was {text:?}"); + let v: Value = serde_json::from_str(text.trim()).unwrap(); + let e = match &v { + Value::Array(a) => a[0].clone(), + other => other.clone(), + }; + assert_eq!(e["error"]["code"], -32600, "input {input}"); + assert_eq!(e["id"], Value::Null, "input {input}"); + } + } + + #[test] + fn notifications_still_get_no_reply() { + assert_eq!(reply(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), ""); + let v: Value = serde_json::from_str(reply(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).trim()).unwrap(); + assert_eq!(v["id"], 1); + } +} diff --git a/crates/clap-host/Cargo.toml b/crates/clap-host/Cargo.toml index 96c7de7c..fa719ac7 100644 --- a/crates/clap-host/Cargo.toml +++ b/crates/clap-host/Cargo.toml @@ -25,14 +25,16 @@ harness = false clap-sys = "0.5" libloading = "0.9" -# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace +# The isolated unsafe helper crate: this crate opts out of the workspace # `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the # FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`. [lints.rust] unsafe_code = "deny" [lints.clippy] +cognitive_complexity = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" +too_many_arguments = "deny" type_complexity = "allow" diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index ab589957..8ae25c52 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -54,7 +54,7 @@ pub(crate) use events::Event; #[cfg(test)] use events::transport_event; pub(crate) use gui::GuiLink; -pub(crate) use instance::{Instance, NotePort, PortBuffers, PortInfo, RawParam}; +pub(crate) use instance::{Instance, NotePort, PortBuffers, PortInfo, ProcessInput, RawParam}; /// Most plugins one factory may report (hostile-input cap). const MAX_PLUGINS_PER_FACTORY: u32 = 1024; diff --git a/crates/clap-host/src/ffi/bundle.rs b/crates/clap-host/src/ffi/bundle.rs index e3cdeddc..503b6391 100644 --- a/crates/clap-host/src/ffi/bundle.rs +++ b/crates/clap-host/src/ffi/bundle.rs @@ -1,7 +1,12 @@ use super::gui::{GuiInner, GuiLink}; use super::host::{HostState, host_get_extension, host_request_callback, host_request_process, host_request_restart}; use super::instance::Instance; -use super::*; +use super::{ + Arc, CLAP_EXT_AUDIO_PORTS, CLAP_EXT_GUI, CLAP_EXT_LATENCY, CLAP_EXT_NOTE_PORTS, CLAP_EXT_PARAMS, CLAP_EXT_STATE, CLAP_PLUGIN_FACTORY_ID, + CLAP_VERSION, CString, ClapError, EVENT_CAP, HashMap, MAX_FEATURES, MAX_PLUGINS_PER_FACTORY, Mutex, OnceLock, Path, PathBuf, PoisonError, c_void, + clap_host, clap_plugin_audio_ports, clap_plugin_entry, clap_plugin_factory, clap_plugin_gui, clap_plugin_latency, clap_plugin_note_ports, + clap_plugin_params, clap_plugin_state, clap_version_is_compatible, read_cstr, scan, +}; // ---- loaded binaries ----------------------------------------------------------------------- diff --git a/crates/clap-host/src/ffi/events.rs b/crates/clap-host/src/ffi/events.rs index 1bc2f3f8..d3bb04fe 100644 --- a/crates/clap-host/src/ffi/events.rs +++ b/crates/clap-host/src/ffi/events.rs @@ -1,4 +1,10 @@ -use super::*; +use super::{ + CLAP_BEATTIME_FACTOR, CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, + CLAP_EVENT_TRANSPORT, CLAP_SECTIME_FACTOR, CLAP_TRANSPORT_HAS_BEATS_TIMELINE, CLAP_TRANSPORT_HAS_SECONDS_TIMELINE, CLAP_TRANSPORT_HAS_TEMPO, + CLAP_TRANSPORT_HAS_TIME_SIGNATURE, CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_RECORDING, MAX_STATE_BYTES, Transport, c_void, clap_event_header, + clap_event_midi, clap_event_note, clap_event_param_value, clap_event_transport, clap_input_events, clap_istream, clap_ostream, + clap_output_events, +}; // ---- events -------------------------------------------------------------------------------- diff --git a/crates/clap-host/src/ffi/gui.rs b/crates/clap-host/src/ffi/gui.rs index 5a911a95..92619742 100644 --- a/crates/clap-host/src/ffi/gui.rs +++ b/crates/clap-host/src/ffi/gui.rs @@ -1,6 +1,9 @@ use super::events::ostream_write; use super::host::HostState; -use super::*; +use super::{ + Arc, CStr, CString, Mutex, Ordering, PoisonError, c_void, clap_ostream, clap_plugin, clap_plugin_gui, clap_plugin_state, clap_window, + clap_window_handle, hostwin, +}; // ---- editor (clap.gui) --------------------------------------------------------------------- diff --git a/crates/clap-host/src/ffi/host.rs b/crates/clap-host/src/ffi/host.rs index 882c0377..408546dc 100644 --- a/crates/clap-host/src/ffi/host.rs +++ b/crates/clap-host/src/ffi/host.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + AtomicBool, AtomicU64, CLAP_EXT_GUI, CLAP_EXT_LATENCY, CLAP_EXT_LOG, CLAP_EXT_PARAMS, CLAP_EXT_STATE, CLAP_LOG_ERROR, CLAP_LOG_FATAL, + CLAP_LOG_WARNING, CStr, EDIT_CAP, Mutex, Ordering, c_char, c_void, clap_host, clap_host_gui, clap_host_latency, clap_host_log, clap_host_params, + clap_host_state, clap_id, clap_log_severity, clap_param_clear_flags, clap_param_rescan_flags, read_cstr, +}; // ---- host side ----------------------------------------------------------------------------- diff --git a/crates/clap-host/src/ffi/instance.rs b/crates/clap-host/src/ffi/instance.rs index fec2bd44..3a537fb6 100644 --- a/crates/clap-host/src/ffi/instance.rs +++ b/crates/clap-host/src/ffi/instance.rs @@ -2,7 +2,12 @@ use super::bundle::Bundle; use super::events::{Event, RawEvent, ReadCursor, in_events_get, in_events_size, istream_read, out_events_try_push, transport_event}; use super::gui::GuiLink; use super::host::HostState; -use super::*; +use super::{ + Arc, CLAP_AUDIO_PORT_IS_MAIN, CLAP_PROCESS_ERROR, CStr, ClapError, MAX_PARAMS, MAX_PORT_CHANNELS, MAX_PORTS, Ordering, Transport, c_char, c_void, + clap_audio_buffer, clap_audio_port_info, clap_host, clap_input_events, clap_istream, clap_note_port_info, clap_output_events, clap_param_info, + clap_plugin, clap_plugin_audio_ports, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_process, + read_name, +}; // ---- audio buffers ------------------------------------------------------------------------- @@ -312,16 +317,8 @@ impl Instance { /// Runs one `clap_process` call over `frames` frames. Events must be sorted by time; their /// times are made relative to `offset`. Returns the CLAP process status. - pub fn process( - &mut self, - frames: usize, - steady_time: i64, - inputs: &mut PortBuffers, - outputs: &mut PortBuffers, - events: &[Event], - offset: u32, - transport: Option<&Transport>, - ) -> Result { + pub fn process(&mut self, input: ProcessInput<'_>) -> Result { + let ProcessInput { frames, steady_time, inputs, outputs, events, offset, transport } = input; if !self.active { return Err(ClapError::Process("not active".into())); } @@ -386,6 +383,16 @@ impl Instance { } } +pub(crate) struct ProcessInput<'a> { + pub frames: usize, + pub steady_time: i64, + pub inputs: &'a mut PortBuffers, + pub outputs: &'a mut PortBuffers, + pub events: &'a [Event], + pub offset: u32, + pub transport: Option<&'a Transport>, +} + impl Drop for Instance { fn drop(&mut self) { // Editors first (CLAP: destroy the GUI before the plugin), and no handle may call in after. diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs index a5e042a3..7142a9ee 100644 --- a/crates/clap-host/src/lib.rs +++ b/crates/clap-host/src/lib.rs @@ -5,7 +5,7 @@ //! plugins exactly like the built-in ones. Plugin ids are `clap:`; parameter ids //! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`]. //! -//! This is SoundCraft's single isolated `unsafe` crate (craftrules never-crash): `unsafe` is +//! This is SoundCraft's single isolated `unsafe` crate: `unsafe` is //! denied crate-wide and allowed only in the private `ffi` module. The public API is safe, returns //! `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that itself crashes //! (segfault) takes the process down with it; we cannot sandbox native code. diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs index 4b685cf8..8e048e2a 100644 --- a/crates/clap-host/src/plugin.rs +++ b/crates/clap-host/src/plugin.rs @@ -1,6 +1,6 @@ //! `ClapPlugin`: a loaded CLAP instance behind the safe `soundcraft_dsp::Plugin` trait. -use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers}; +use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers, ProcessInput}; use clap_sys::ext::note_ports::{CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI}; use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport}; use std::sync::Arc; @@ -231,7 +231,15 @@ impl Plugin for ClapPlugin { let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let events = self.queue.get(..k).unwrap_or(&[]); - let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset, self.transport.as_ref()); + let r = self.inst.process(ProcessInput { + frames: n, + steady_time: self.steady, + inputs: &mut self.inputs, + outputs: &mut self.outputs, + events, + offset, + transport: self.transport.as_ref(), + }); self.queue.drain(..k); self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0)); match (r, self.out_main) { diff --git a/crates/dsp/src/meter.rs b/crates/dsp/src/meter.rs index 442985d5..f5d15d53 100644 --- a/crates/dsp/src/meter.rs +++ b/crates/dsp/src/meter.rs @@ -395,3 +395,63 @@ impl LoudnessMeter { gain_to_db(self.true_peak) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn noise(len: usize, seed: u64, amp: f32) -> Vec { + let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + ((x >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0) * amp + }) + .collect() + } + + /// True peak as first written: the history shifted every sample, each phase summed on its own. + fn true_peak_reference(coefs: &[[f32; TP_PHASES]; TP_TAPS], x: &[f32]) -> f32 { + let mut h = [0.0f32; TP_TAPS]; + let mut tp = 0.0f32; + for v in x.iter().map(|v| if v.is_finite() { *v } else { 0.0 }) { + h.copy_within(0..TP_TAPS - 1, 1); + h[0] = v; + let mut peak = v.abs(); + for p in 0..TP_PHASES { + let y: f32 = coefs.iter().zip(h.iter()).map(|(c, b)| c[p] * b).sum(); + peak = peak.max(y.abs()); + } + if peak > tp { + tp = peak; + } + } + tp + } + + #[test] + fn true_peak_matches_the_shifted_history() { + let sr = 48_000.0; + let len = 20_000; + let mut l: Vec = + (0..len).map(|i| 0.6 * (std::f32::consts::TAU * 0.25 * i as f32 + 0.7).sin()).zip(noise(len, 1, 0.3)).map(|(a, b)| a + b).collect(); + let r = noise(len, 2, 0.9); + l[123] = f32::NAN; + l[4_567] = f32::INFINITY; + for block in [1, 7, 480, len] { + let mut m = LoudnessMeter::new(sr, 2); + let want = true_peak_reference(&m.tp_coefs, &l).max(true_peak_reference(&m.tp_coefs, &r)); + for at in (0..len).step_by(block) { + let n = block.min(len - at); + m.process(&[l[at..at + n].to_vec(), r[at..at + n].to_vec()], n); + } + assert_eq!(m.true_peak.to_bits(), want.to_bits(), "block {block}"); + m.reset(); + let quiet = noise(500, 3, 0.1); + m.process(&[quiet.clone(), quiet.clone()], 500); + assert_eq!(m.true_peak.to_bits(), true_peak_reference(&m.tp_coefs, &quiet).to_bits(), "after reset, block {block}"); + } + } +} diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index bc915375..f8d23784 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -127,8 +127,11 @@ pub fn remove_dc(ch: &mut [Vec]) { if c.is_empty() { continue; } - let mean = c.iter().filter(|v| v.is_finite()).map(|&v| f64::from(v)).sum::() / c.len() as f64; - let m = mean as f32; + let (sum, n) = c.iter().filter(|v| v.is_finite()).fold((0.0f64, 0usize), |(s, n), &v| (s + f64::from(v), n + 1)); + if n == 0 { + continue; + } + let m = (sum / n as f64) as f32; c.iter_mut().for_each(|s| *s -= m); } } @@ -889,4 +892,14 @@ mod tests { assert_eq!(ch[0][50], 1.0); fade(&mut ch, 1000, 1000, FadeShape::SCurve); } + + #[test] + fn remove_dc_ignores_non_finite_samples_in_the_mean() { + let mut ch = vec![vec![f32::NAN, 1.0, 1.0, 1.0]]; + remove_dc(&mut ch); + assert!(ch[0][1..].iter().all(|x| x.abs() < 1e-6), "left over dc {:?}", ch[0]); + let mut all_bad = vec![vec![f32::NAN, f32::INFINITY]]; + remove_dc(&mut all_bad); + assert!(all_bad[0][0].is_nan() && all_bad[0][1].is_infinite()); + } } diff --git a/crates/dsp/src/plugins/dynamics.rs b/crates/dsp/src/plugins/dynamics.rs index 9d998e4c..1b8751dc 100644 --- a/crates/dsp/src/plugins/dynamics.rs +++ b/crates/dsp/src/plugins/dynamics.rs @@ -671,3 +671,44 @@ impl Plugin for Maximizer { self.gr_meter } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The window minimum as first written: the last `la` requests in a ring, scanned whole. + #[test] + fn window_min_matches_a_scan_of_the_last_requests() { + let mut x = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + x + }; + for la in [1usize, 2, 3, 7, 64, 480] { + let mut m = Maximizer::new(); + m.win = vec![(0, 1.0); la]; + let mut ring = vec![1.0f32; la]; + let mut at = 0; + for i in 0..6_000 { + if i == 3_000 { + m.reset(); + ring.iter_mut().for_each(|v| *v = 1.0); + at = 0; + } + let r = next(); + let req = match r % 16 { + 0 => f32::NAN, + 1 => 1.0, + 2 => 0.25, + _ => (r >> 40) as f32 / (1u64 << 24) as f32 * 1.5, + }; + ring[at] = req; + at = (at + 1) % la; + let want = ring.iter().copied().fold(1.0f32, f32::min); + assert_eq!(m.window_min(req, la).to_bits(), want.to_bits(), "la {la}, sample {i}"); + } + } + } +} diff --git a/crates/dsp/src/util.rs b/crates/dsp/src/util.rs index 3ec2f705..d20b1afb 100644 --- a/crates/dsp/src/util.rs +++ b/crates/dsp/src/util.rs @@ -310,3 +310,17 @@ pub(crate) fn sort_events(ev: &mut [T], key: impl Fn(&T) -> usize) { pub(crate) fn note_hz(note: f32) -> f32 { 440.0 * 2f32.powf((note - 69.0) / 12.0) } + +#[cfg(test)] +mod tests { + #[test] + fn flush_matches_the_finite_and_magnitude_test() { + let edge = 1.0e-25f32.to_bits(); + let special = [0, 0x8000_0000, 1, edge - 1, edge, edge + 1, 0x7f7f_ffff, 0x7f80_0000, 0xff80_0000, 0x7fc0_0000, 0xffc0_0001]; + for b in (0..=u32::MAX).step_by(997).chain(special).chain(special.map(|b| b | 0x8000_0000)) { + let x = f32::from_bits(b); + let want = if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 }; + assert_eq!(super::flush(x).to_bits(), want.to_bits(), "{b:#x}"); + } + } +} diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index d725efec..516664da 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -255,7 +255,7 @@ impl Autosave { /// Delete the folder: the session closed with nothing left to recover. fn discard(mut self) { - drop(self.lock.take()); + self.lock = None; let _ = std::fs::remove_dir_all(&self.dir); } } @@ -286,6 +286,7 @@ pub fn leftovers(root: &Path) -> Vec { let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok()); let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified()); let recording = dir.join(RECORDING).is_file(); + #[cfg(not(target_arch = "wasm32"))] drop(lock); match saved { Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => { diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index 18102bc4..c2e0bdb9 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -89,7 +89,11 @@ fn decode_group_audio( let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new()); for (old, rel) in audio_entries { let full = dir.join(rel); - if !full.starts_with(dir) || rel.contains("..") { + if !full.starts_with(dir) + || Path::new(rel) + .components() + .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_))) + { return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}"))); } let key = crate::io::file_key(&full); @@ -233,6 +237,40 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn import_accepts_audio_names_with_consecutive_dots_but_not_parent_dirs() { + let dir = std::env::temp_dir().join(format!("sc-grp-dots-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("drums.scgrp").to_string_lossy().to_string(); + let mut e = crate::demo::demo_engine(); + let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap(); + let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect(); + e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap(); + let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + for (i, rel) in file["audio"].as_object_mut().unwrap().values_mut().enumerate() { + let old = dir.join(rel.as_str().unwrap()); + let new = format!("Wait... what_{i}.wav"); + std::fs::rename(&old, dir.join(&new)).unwrap(); + *rel = json!(new); + } + std::fs::write(&path, file.to_string()).unwrap(); + let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"})); + assert!(r.is_ok(), "{r:?}"); + for rel in file["audio"].as_object_mut().unwrap().values_mut() { + *rel = json!("../escape.wav"); + } + std::fs::write(&path, file.to_string()).unwrap(); + let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"})); + assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "{r:?}"); + for rel in file["audio"].as_object_mut().unwrap().values_mut() { + *rel = json!(dir.join("Wait... what_0.wav").to_string_lossy()); + } + std::fs::write(&path, file.to_string()).unwrap(); + let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"})); + assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn imported_clips_carry_no_auto_rename_scheme() { let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id())); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index b943c5c1..cbba05c1 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -28,6 +28,7 @@ mod plugin_state; mod query; pub(crate) mod sampler; mod setup_more; +mod strip_silence; mod surround; mod tab; mod track; @@ -63,7 +64,7 @@ mod panic_probe { pub use file::bounce_job; pub(crate) use edit::remember_selection; -pub use edit::strip_silence_scan; +pub use strip_silence::strip_silence_scan; pub type Run = fn(&mut Engine, &Value) -> Result; /// `Err(reason)` = disabled. @@ -160,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(sampler::specs()); v.extend(video::specs()); v.extend(zoom_toggle::specs()); + v.extend(strip_silence::specs()); #[cfg(test)] v.extend(panic_probe::specs()); v @@ -322,11 +324,15 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result Result { - let st = position_param(e, cmd, p, "start")?; + let mut st = if p.get("start").is_some_and(Value::is_null) { None } else { position_param(e, cmd, p, "start")? }; let en = position_param(e, cmd, p, "end")?; let len = position_param(e, cmd, p, "length")?; + if st.is_none() && (en.is_some() || len.is_some()) { + st = Some(e.session().edit.selection.start); + } match (st, en, len) { (Some(a), Some(b), _) => Ok(Range::new(a, b)), (Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))), diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index 5d727119..0f7ba25f 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -137,6 +137,7 @@ fn process(e: &mut Engine, p: &Value) -> Result { nc.name = new_name; nc.content = ClipContent::Audio { source: src, offset: 0 }; nc.length = i64::try_from(frames).unwrap_or(nc.length); + nc.clamp_fades(); // The render already has the clip's stretch applied; keeping it would stretch the new file again. nc.stretch = 1.0; // Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain, @@ -246,6 +247,21 @@ mod tests { assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}"); } + #[test] + fn shortening_process_clamps_fades_to_the_new_length() { + let (mut e, id) = gained_clip(); + { + let c = e.session_mut().find_clip_mut(id).unwrap(); + c.fade_in.len = 60; + c.fade_out.len = 40; + } + e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 0.25}, "clips": [id.0]})).unwrap(); + let c = e.session().tracks[0].clips()[0].clone(); + assert!(c.length < 64, "{}", c.length); + assert!(c.fade_in.len <= c.length, "fade in {} > length {}", c.fade_in.len, c.length); + assert_eq!(c.fade_out.len, 40.min(c.length)); + } + #[test] fn processed_clip_does_not_keep_the_stretch_it_baked() { for stretch in [0.5, 2.0] { diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 89340187..8cbbf797 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -228,6 +228,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result { nc.content = soundcraft_model::ClipContent::Audio { source: src, offset: 0 }; nc.length = out_len; nc.stretch = 1.0; + nc.clamp_fades(); super::more_util::replace_clip(s, t, *id, nc); rendered += 1; } @@ -246,7 +247,7 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result { if let Some((src, off, len)) = orig { c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off }; c.length = len.max(1); - c.fade_out.len = c.fade_out.len.min(c.length); + c.clamp_fades(); n += 1; } } @@ -496,6 +497,27 @@ mod tests { } } + #[test] + fn warping_and_unwarping_keep_fades_within_the_clip() { + let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000); + if let Some(c) = e.session_mut().find_clip_mut(cid) { + c.fade_in.len = 20_000; + c.fade_out.len = 20_000; + } + e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap(); + let c = e.session().find_clip(cid).unwrap().1.clone(); + assert!(c.fade_in.len <= c.length && c.fade_out.len <= c.length, "{} {} {}", c.length, c.fade_in.len, c.fade_out.len); + + let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000); + e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap(); + if let Some(c) = e.session_mut().find_clip_mut(cid) { + c.fade_in.len = 40_000; + } + e.execute("clip.remove_warp", &json!({"clips": [cid.0]})).unwrap(); + let c = e.session().find_clip(cid).unwrap().1.clone(); + assert!(c.fade_in.len <= c.length, "{} {}", c.length, c.fade_in.len); + } + #[test] fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index a2ebd8b3..7a2564c8 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -196,8 +196,14 @@ fn unloop(e: &mut Engine, p: &Value) -> Result { fn effects_set(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let params = p.get("params").and_then(Value::as_object).ok_or_else(|| bad("clip.effects_set", "`params` must be an object of numbers"))?; - let vals: Vec<(String, f64)> = - params.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))).collect(); + let vals: Vec<(String, f64)> = params + .iter() + .filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))) + .map(|(k, v)| { + let v = if k == "gain" { v.clamp(f64::from(soundcraft_dsp::MIN_DB), f64::from(soundcraft_mix::CLIP_FX_MAX_GAIN_DB)) } else { v }; + (k, v) + }) + .collect(); let s = e.session_mut(); for c in &ids { for (k, v) in &vals { @@ -316,6 +322,13 @@ mod tests { (Engine::new(s), t, ids) } + #[test] + fn effects_set_caps_gain() { + let (mut e, _t, ids) = engine_with(&[(0, 100, 0)]); + e.execute("clip.effects_set", &json!({"clip": ids[0].0, "params": {"gain": 500.0}})).unwrap(); + assert_eq!(e.session().edit.values.get(&format!("clip_fx.{}.gain", ids[0].0)).copied(), Some(24.0)); + } + #[test] fn unloop_removes_copies() { let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100, 100, 0), (200, 100, 0), (300, 100, 50)]); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 06e6bec0..65c88f4a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,7 +1,7 @@ //! Edit menu commands. use super::{ - CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection, - has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required, + CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, has_clipboard, has_range, has_selection, has_tracks, + i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required, }; use soundcraft_time::Samples; @@ -125,15 +125,6 @@ pub fn specs() -> Vec { has_range, strip_silence ), - cmd!( - query "edit.strip_silence_preview", - "Strip Silence Rectangles", - [], - None, - "{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)", - has_range, - strip_silence_preview - ), cmd!( "edit.fades_create", "Create...", @@ -385,13 +376,7 @@ fn clear(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let mut n = 0; if range.is_empty() && !ids.is_empty() { - for t in &mut s.tracks { - if let Some(pl) = t.playlist_mut() { - let before = pl.clips.len(); - pl.clips.retain(|c| !ids.contains(&c.id)); - n += before - pl.clips.len(); - } - } + n = ops::remove_clips(s, &ids, sh); s.edit.selected_clips.clear(); } else { for t in &tracks { @@ -821,38 +806,8 @@ fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result { Ok(json!({"tracks": tracks.len()})) } -/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads. -type StripSettings = (Vec, soundcraft_time::Range, f32, Samples, (Samples, Samples)); - -/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the -/// command in errors. -fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result { - let tracks = tracks_required(e, cmd, p)?; - let r = range_param(e, cmd, p)?; - let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0); - let sr = e.session().sample_rate; - let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0); - Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0)))) -} - -/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit -/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a -/// worker thread outside the web build, and on the frame thread in the web build or when no thread -/// can be started. -pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result { - let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?; - Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() }) -} - -fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result { - let found = strip_silence_scan(e, p)?.run()?; - let tracks: Vec = - found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::>()})).collect(); - Ok(json!({"tracks": tracks})) -} - fn strip_silence(e: &mut Engine, p: &Value) -> Result { - let (tracks, r, thr, min_len, (pre, post)) = strip_settings(e, "edit.strip_silence", p)?; + let (tracks, r, thr, min_len, (pre, post)) = super::strip_silence::strip_settings(e, "edit.strip_silence", p)?; let mode = match p.get("mode") { None => crate::io::StripMode::Strip, Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?, diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 018209b6..d9271210 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -115,11 +115,13 @@ fn tempo_constant(e: &mut Engine, p: &Value) -> Result { let r = range_param(e, id, p)?; let s = e.session(); let (t0, t1) = (s.tempo.samples_to_ticks(r.start, s.sample_rate), s.tempo.samples_to_ticks(r.end, s.sample_rate)); - if t1 <= t0 { - return tempo(e, p); - } let bpm = f64_or(p, "bpm", f64::NAN); let s = e.session_mut(); + if t1 <= t0 { + // An empty range sets the tempo at its start, which may come from the selection. + s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?; + return Ok(json!({"tick": t0, "bpm": bpm})); + } // Read before the edit: the end is a musical position, and the new tempo moves it in samples. let after = s.tempo.tempo_at_tick(t1); let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1); @@ -163,6 +165,13 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result { if m.start >= r.start { m.start += r.len(); m.end += r.len(); + } else if m.end > r.start { + m.end += r.len(); + } + } + for (at, _) in &mut s.key_signatures { + if *at >= r.start { + *at = at.saturating_add(r.len()); } } Ok(json!({"inserted": r.len()})) @@ -190,6 +199,16 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result { if m.start >= r.end { m.start -= r.len(); m.end -= r.len(); + } else if m.end >= r.end { + m.end -= r.len(); + } else if m.end > r.start { + m.end = r.start; + } + } + s.key_signatures.retain(|(at, _)| !r.contains(*at)); + for (at, _) in &mut s.key_signatures { + if *at >= r.end { + *at -= r.len(); } } Ok(json!({"removed": r.len()})) @@ -302,7 +321,10 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result { (None, Some(a), Some(b)) => (a, b), (None, Some(p), None) | (None, None, Some(p)) => (p, p), _ if kind == MarkerKind::Selection => (sel.start, sel.end), - _ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start), + _ => { + let point = if e.transport.playing { e.transport.position } else { sel.start }; + (point, point) + } }; let s = e.session_mut(); let n = s.markers.len() + 1; @@ -407,6 +429,46 @@ mod tests { use crate::demo::demo_engine; use serde_json::json; + #[test] + fn tempo_constant_with_an_empty_range_sets_the_tempo_at_its_start() { + use soundcraft_time::Range; + let mut e = demo_engine(); + e.session_mut().edit.selection = Range::point(96_000); + let r = e.execute("event.tempo_constant", &json!({"bpm": 90.0, "end": 96_000})).unwrap(); + let s = e.session(); + let tick = s.tempo.samples_to_ticks(96_000, s.sample_rate); + assert_eq!(r["tick"], tick); + assert_eq!(s.tempo.tempo_at_tick(tick), 90.0); + } + + /// `start` is optional for insert/cut time: it defaults to the selection start, then `end`/`length` apply. + #[test] + fn insert_and_cut_time_without_start_use_selection_start() { + use soundcraft_time::Range; + let mut e = demo_engine(); + let a = e.session().edit.selection.start; + let run = |e: &mut crate::Engine, sel: Range, id: &str, params: serde_json::Value| { + e.session_mut().edit.selection = sel; + e.execute(id, ¶ms).unwrap() + }; + // Point selection. + assert_eq!(run(&mut e, Range::point(a), "event.insert_time", json!({"length": 48000}))["inserted"], 48000); + assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"end": a + 5000}))["removed"], 5000); + assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"length": 700}))["removed"], 700); + // A non-empty selection must not override the given `end`/`length`. + let sel = Range::new(a, a + 100); + assert_eq!(run(&mut e, sel, "event.insert_time", json!({"length": 48000}))["inserted"], 48000); + assert_eq!(run(&mut e, sel, "event.insert_time", json!({"end": a + 2000}))["inserted"], 2000); + assert_eq!(run(&mut e, sel, "event.cut_time", json!({"end": a + 5000}))["removed"], 5000); + // A null `start` takes the same path. + assert_eq!(run(&mut e, sel, "event.cut_time", json!({"start": null, "end": a + 3000}))["removed"], 3000); + assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": null, "length": 900}))["inserted"], 900); + // An explicit `start` still wins over the selection. + let r = run(&mut e, sel, "event.cut_time", json!({"start": a + 1000, "end": a + 3000})); + assert_eq!(r["removed"], 2000); + assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": a + 1000, "length": 500}))["inserted"], 500); + } + /// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position. #[test] fn tempo_change_at_position_is_applied() { @@ -473,6 +535,19 @@ mod tests { assert_eq!(find(&c), Some((1000, 2000))); } + /// A plain marker added during playback is a point at the playhead, whatever the edit selection is. + #[test] + fn markers_add_while_playing_is_a_point_at_the_playhead() { + let mut e = demo_engine(); + e.session_mut().edit.selection.start = 48_000; + e.session_mut().edit.selection.end = 48_000; + e.transport.playing = true; + e.transport.position = 4800; + let r = e.execute("markers.add", &json!({"name": "M"})).unwrap(); + let m = e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end)); + assert_eq!(m, Some((4800, 4800))); + } + /// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it. #[test] fn edit_marker_sets_documented_color() { diff --git a/crates/engine/src/cmd/event_more.rs b/crates/engine/src/cmd/event_more.rs index 678cf3aa..6f5b73b3 100644 --- a/crates/engine/src/cmd/event_more.rs +++ b/crates/engine/src/cmd/event_more.rs @@ -274,7 +274,12 @@ fn tempo_curve(e: &mut Engine, p: &Value, curve: Curve, id: &str) -> Result Result { let id = "event.tempo_stretch"; - let (r, t0, t1) = selection_ticks(e, p, id)?; + // Here `length` is the new length of the selection, not part of the range. + let mut range_params = p.clone(); + if let Some(m) = range_params.as_object_mut() { + m.remove("length"); + } + let (r, t0, t1) = selection_ticks(e, &range_params, id)?; let factor = match position_param(e, id, p, "length")? { Some(l) if l > 0 => l as f64 / r.len() as f64, _ => f64_or(p, "factor", f64::NAN), @@ -901,6 +906,15 @@ mod tests { } } + /// For `event.tempo_stretch`, `length` is the new length of the selection, so the selection stays the range. + #[test] + fn tempo_stretch_length_keeps_selection_range() { + let mut e = Engine::default(); + e.session_mut().edit.selection = Range::new(0, 96_000); + e.execute("event.tempo_stretch", &json!({"length": 192_000})).unwrap(); + assert_eq!(e.session().tempo.tempo_at_tick(0), 60.0); + } + #[test] fn tempo_curves() { let mut e = Engine::default(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f221014c..6cac6607 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -335,7 +335,11 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result return Err(bad(id, "no MIDI or instrument tracks to notate")); } let setup = crate::score::ScoreSetup::from_session(e.session()); - let (text, ext) = if svg { (crate::score::svg(&parts, &setup), "svg") } else { (crate::score::musicxml(&parts, &setup), "musicxml") }; + let (text, ext) = if svg { + (crate::score::svg(&parts, &setup).map_err(|m| bad(id, m))?, "svg") + } else { + (crate::score::musicxml(&parts, &setup).map_err(|m| bad(id, m))?, "musicxml") + }; let path = match str_param(p, "path") { Some(p) => p.to_string(), None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(), diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index 0eb61cbc..b0a2faa0 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -124,6 +124,12 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result { let dt = i64_or(p, "ticks", 0); let ds = i64_or(p, "semitones", 0); let seq = seq_mut(e, id, "midi.notes_move")?; + // Clamp the shared delta so the earliest selected note stops at tick 0 and the selection keeps its spacing. + let earliest = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| n.start).min(); + let dt = match earliest { + Some(m) => dt.max(0i64.saturating_sub(m).min(0)), + None => dt, + }; for (i, n) in seq.notes.iter_mut().enumerate() { if idx.contains(&i) { n.start = n.start.saturating_add(dt).max(0); @@ -168,6 +174,36 @@ mod tests { assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err()); } + #[test] + fn notes_move_left_past_zero_keeps_spacing() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap(); + let clip = r["clip"].as_u64().unwrap(); + for s in [100, 200, 300] { + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap(); + } + e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": -150})).unwrap(); + let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); + let starts: Vec = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect(); + assert_eq!(starts, [0, 100, 300]); + } + + #[test] + fn notes_move_without_left_overflow_does_not_shift_selection() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap(); + for s in [0, 100] { + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap(); + } + seq_mut(&mut e, ClipId(clip), "test").unwrap().notes[0].start = -50; + e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": 0})).unwrap(); + let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); + let starts: Vec = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect(); + assert_eq!(starts, [0, 100]); + } + #[test] fn notes_move_huge_params_do_not_overflow() { let mut e = Engine::default(); diff --git a/crates/engine/src/cmd/more_util.rs b/crates/engine/src/cmd/more_util.rs index 3d68b6c0..7b63a838 100644 --- a/crates/engine/src/cmd/more_util.rs +++ b/crates/engine/src/cmd/more_util.rs @@ -155,6 +155,14 @@ pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples) if !(0.1..=10.0).contains(&ratio) { return Err(bad(cmd, format!("stretch ratio {ratio:.3} is outside 0.1..10"))); } + // The source span read in can be 10× the output, and both are held for every channel: bound + // each like any other offline render (a session's clip length and stretch are not trusted). + let channels = audio.buffer.channels.len().max(1); + let limit = crate::io::render_limit(channels); + if src_len > limit as f64 || usize::try_from(out_len).map_or(true, |n| n > limit) { + let secs = limit as f64 / s.sample_rate.as_f64(); + return Err(bad(cmd, format!("the clip is too long to stretch: one render holds at most {secs:.0} s of {channels}-channel audio"))); + } let a = usize::try_from(offset.max(0)).unwrap_or(0); let n = src_len as usize; let ch: Vec> = audio @@ -213,4 +221,38 @@ mod tests { s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap(); assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples); } + + /// A stereo session with a one-frame source and one clip on it. + fn stretched_clip(length: Samples, stretch: f64) -> (Session, Clip) { + use soundcraft_model::{ChannelFormat, TrackKind}; + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Long")); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5]; 2] }; + let src = crate::io::add_source(&mut s, "One", buf, None, FileFormat::Wav); + let cid = s.new_clip_id(); + let mut clip = Clip::audio(cid, "Clip", src, 0, 0, length); + clip.stretch = stretch; + crate::edit::place_clip(&mut s, t, clip.clone()); + (s, clip) + } + + /// #895: a session's clip length and stretch made the source span read in 10× the one-hour + /// output limit, for every channel (about 14 GB here), and the allocation aborted the app. + #[test] + fn a_stretch_over_the_render_limit_is_refused_before_allocating() { + let hour = Session::default().sample_rate.samples(3600.0); + let (mut s, clip) = stretched_clip(hour, 0.1); + let err = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap_err(); + assert!(err.to_string().contains("too long to stretch"), "{err}"); + + let mut e = crate::Engine::new(s); + assert!(e.execute("clip.remove_pitch_shift", &serde_json::json!({"clips": [clip.id.0]})).is_err()); + let (_, kept) = e.session().find_clip(clip.id).unwrap(); + assert_eq!((kept.stretch, kept.length), (0.1, hour), "the clip is left as it was"); + + // An ordinary stretch still renders, at the length asked for. + let (mut s, clip) = stretched_clip(1_000, 0.5); + let src = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap().unwrap(); + assert!(s.pool.get(src).unwrap().buffer.channels.iter().all(|c| c.len() == 1_000)); + } } diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index 9fe82b08..3d07deb1 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -42,13 +42,24 @@ pub fn specs() -> Vec { always, prepare_dpe ), - cmd!(noundo "setup.hardware", "Hardware...", ["Setup"], None, "{device?, buffer_size?: 16..8192} — audio interface settings (sample rate is the session's)", always, |e, p| engine_settings(e, p)), - cmd!(noundo "setup.playback_engine", "Playback Engine...", ["Setup"], None, "{device?, buffer_size?: 16..8192, delay_compensation?: bool}", always, |e, p| { - if let Some(v) = p.get("delay_compensation").and_then(Value::as_bool) { - e.session_mut().edit.delay_compensation = v; - } - engine_settings(e, p) - }), + cmd!( + noundo "setup.hardware", + "Hardware...", + ["Setup"], + None, + "{device?: output name, buffer_size?: 16..=8192 or null} — output device and buffer. Applied only in the desktop app (Playback Engine window / ui.audio_device), which returns the open device. Headless stores the request and returns applied: false; buffer_size is null when unset. Any integer in range, not rounded to the window's power-of-two sizes. Sample rate is the session's", + always, + |e, p| engine_settings(e, p, "setup.hardware") + ), + cmd!( + noundo "setup.playback_engine", + "Playback Engine...", + ["Setup"], + None, + "{device?: output name, buffer_size?: 16..=8192 or null, delay_compensation?: bool} — device and buffer as setup.hardware (applied only in the desktop app, or via ui.audio_device); delay_compensation is applied here too. Headless returns applied: false and does not invent a buffer", + always, + |e, p| engine_settings(e, p, "setup.playback_engine") + ), cmd!(noundo "setup.disk_allocation", "Disk Allocation...", ["Setup"], None, "{path?, track?} — the record folder for the session, or for one track", always, disk_allocation), cmd!(noundo "setup.peripherals", "Peripherals...", ["Setup"], None, "{settings?: {name: number|bool|string}} — control surface / sync device settings", always, peripherals), cmd!( @@ -156,18 +167,69 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result { Ok(json!({"prepared": n})) } -fn engine_settings(e: &mut Engine, p: &Value) -> Result { +/// A `buffer_size` argument: absent, cleared (`null`), or an integer in 16..=8192. +enum BufferParam { + Skip, + Clear, + Set(u64), +} + +/// Same range as `ui.audio_device`. Out of range and non-numbers error; there is no clamp and no +/// power-of-two rounding (the Playback Engine window lists those sizes, the command keeps the number). +fn buffer_param(id: &str, p: &Value) -> Result { + match p.get("buffer_size") { + None => Ok(BufferParam::Skip), + Some(Value::Null) => Ok(BufferParam::Clear), + Some(v) => { + let n = v.as_u64().filter(|n| (16..=8192).contains(n)).ok_or_else(|| bad(id, "`buffer_size` must be a number in 16..=8192 or null"))?; + Ok(BufferParam::Set(n)) + } + } +} + +/// `device` as `ui.audio_device`'s `output`: a name, or `null`/empty to clear. Absent leaves it. +fn device_param<'a>(id: &str, p: &'a Value) -> Result>> { + match p.get("device") { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(Value::String(name)) => Ok(Some((!name.is_empty()).then_some(name.as_str()))), + Some(_) => Err(bad(id, "`device` must be a string or null")), + } +} + +/// Headless `setup.hardware` / `setup.playback_engine`. Playback never reads `engine.device` or +/// `engine.buffer_size` (only this command does); they are kept so a later call can report the +/// request. The result says `applied: false` and leaves `buffer_size` null until one was stored. +fn engine_settings(e: &mut Engine, p: &Value, id: &str) -> Result { + let buffer = buffer_param(id, p)?; + let device = device_param(id, p)?; + if id == "setup.playback_engine" + && let Some(on) = p.get("delay_compensation").and_then(Value::as_bool) + { + e.session_mut().edit.delay_compensation = on; + } let s = e.session_mut(); - if let Some(b) = p.get("buffer_size").and_then(Value::as_u64) { - let b = b.clamp(16, 8192).next_power_of_two().min(8192); - s.edit.values.insert("engine.buffer_size".into(), b as f64); + match buffer { + BufferParam::Skip => {} + BufferParam::Clear => { + s.edit.values.remove("engine.buffer_size"); + } + BufferParam::Set(n) => { + s.edit.values.insert("engine.buffer_size".into(), n as f64); + } } - if let Some(d) = str_param(p, "device") { - set_text(&mut s.edit, "engine.device", Some(d)); + if let Some(name) = device { + set_text(&mut s.edit, "engine.device", name); } + // Exact integers only: a hand-edited fraction is not reported as a buffer size. + let buffer_size = s.edit.values.get("engine.buffer_size").copied().and_then(|v| { + let n = v as u64; + (v.is_finite() && (n as f64) == v).then_some(n) + }); Ok(json!({ + "applied": false, "device": text(&s.edit, "engine.device"), - "buffer_size": s.edit.value("engine.buffer_size", 1024.0), + "buffer_size": buffer_size, "sample_rate": s.sample_rate.hz(), "bit_depth": s.bit_depth, "delay_compensation": s.edit.delay_compensation, @@ -307,7 +369,7 @@ fn current_timecode(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let at = s.edit.selection.start.max(0); let abs = tc.to_samples(s.frame_rate, s.sample_rate); - s.timecode_start = abs - at; + s.timecode_start = abs.saturating_sub(at).clamp(-soundcraft_time::MAX_POSITION, soundcraft_time::MAX_POSITION); Ok(json!({"timecode_start": s.timecode_start, "at": at})) } @@ -567,6 +629,20 @@ mod tests { assert_eq!(shown, "01:00:00:00"); } + #[test] + fn current_timecode_out_of_range_is_clamped() { + use soundcraft_time::MAX_POSITION; + for tc in ["-9223372036854775807:00:00:00", "9999999:00:00:00"] { + let mut e = Engine::default(); + e.execute("edit.select", &json!({"start": 48_000})).unwrap(); + e.execute("setup.current_timecode", &json!({"timecode": tc})).unwrap(); + let start = e.session().timecode_start; + assert!((-MAX_POSITION..=MAX_POSITION).contains(&start), "{tc}: start {start}"); + let reloaded = Session::from_json(&e.session().to_json().unwrap()).unwrap(); + assert_eq!(reloaded.timecode_start, start, "{tc}"); + } + } + #[test] fn click_countoff_settings() { let mut e = Engine::default(); @@ -587,7 +663,8 @@ mod tests { fn engine_and_midi_settings() { let mut e = Engine::default(); let r = e.execute("setup.playback_engine", &json!({"buffer_size": 300, "device": "Interface"})).unwrap(); - assert_eq!(r["buffer_size"], 512.0); + assert_eq!(r["applied"], false); + assert_eq!(r["buffer_size"], 300); assert_eq!(r["device"], "Interface"); let r = e.execute("setup.midi_input_filter", &json!({"filter": ["sysex", "bogus"]})).unwrap(); assert_eq!(r["filtered"], json!(["sysex"])); @@ -597,6 +674,45 @@ mod tests { assert_eq!(r["samples"], 3200.0); } + #[test] + fn playback_engine_does_not_pretend_the_device_changed() { + let mut e = Engine::default(); + let r = e.execute("setup.playback_engine", &json!({})).unwrap(); + assert_eq!(r["applied"], false); + assert!(r["buffer_size"].is_null(), "no invented default buffer: {r}"); + assert!(r["device"].is_null()); + assert_eq!(r["delay_compensation"], true, "the session default, not a device claim"); + + let r = e.execute("setup.hardware", &json!({"buffer_size": 300, "device": "Interface"})).unwrap(); + assert_eq!(r["applied"], false); + assert_eq!(r["buffer_size"], 300, "kept, not rounded to a power of two"); + assert_eq!(r["device"], "Interface"); + assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0); + + let r = e.execute("setup.playback_engine", &json!({"delay_compensation": false})).unwrap(); + assert_eq!(r["applied"], false); + assert_eq!(r["delay_compensation"], false); + assert_eq!(r["buffer_size"], 300, "delay compensation does not clear a stored buffer"); + assert!(!e.session().edit.delay_compensation); + + let err = e.execute("setup.playback_engine", &json!({"buffer_size": 8, "delay_compensation": true, "device": "Other"})).unwrap_err(); + assert!(err.to_string().contains("16..=8192"), "{err}"); + assert!(!e.session().edit.delay_compensation, "an invalid buffer changes nothing"); + assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0); + assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface")); + assert!(e.execute("setup.hardware", &json!({"buffer_size": "big"})).is_err()); + + let err = e.execute("setup.playback_engine", &json!({"device": 42, "delay_compensation": true})).unwrap_err(); + assert!(err.to_string().contains("`device` must be a string or null"), "{err}"); + assert!(!e.session().edit.delay_compensation, "an invalid device changes nothing"); + assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface")); + + let r = e.execute("setup.hardware", &json!({"buffer_size": null, "device": ""})).unwrap(); + assert_eq!(r["applied"], false); + assert!(r["buffer_size"].is_null()); + assert!(r["device"].is_null()); + } + #[test] fn import_session_data_brings_tracks_and_audio() { let dir = std::env::temp_dir().join(format!("sc-import-{}", std::process::id())); diff --git a/crates/engine/src/cmd/strip_silence.rs b/crates/engine/src/cmd/strip_silence.rs new file mode 100644 index 00000000..4fbc51c1 --- /dev/null +++ b/crates/engine/src/cmd/strip_silence.rs @@ -0,0 +1,49 @@ +//! Strip Silence Rectangles: the ranges Strip Silence keeps, which the Strip Silence window draws. +use super::{CommandSpec, f32_or, f64_or, has_range, range_param, tracks_required}; +use soundcraft_model::TrackId; +use soundcraft_time::Samples; + +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; + +pub fn specs() -> Vec { + vec![cmd!( + query "edit.strip_silence_preview", + "Strip Silence Rectangles", + [], + None, + "{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)", + has_range, + strip_silence_preview + )] +} + +/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads. +pub(super) type StripSettings = (Vec, soundcraft_time::Range, f32, Samples, (Samples, Samples)); + +/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the +/// command in errors. +pub(super) fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result { + let tracks = tracks_required(e, cmd, p)?; + let r = range_param(e, cmd, p)?; + let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0); + let sr = e.session().sample_rate; + let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0); + Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0)))) +} + +/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit +/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a +/// worker thread outside the web build, and on the frame thread in the web build or when no thread +/// can be started. +pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result { + let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?; + Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() }) +} + +fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result { + let found = strip_silence_scan(e, p)?.run()?; + let tracks: Vec = + found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::>()})).collect(); + Ok(json!({"tracks": tracks})) +} diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 07c6ee9e..7e345fb7 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -7,6 +7,7 @@ use crate::{Engine, Result, cmd}; use serde_json::{Value, json}; use soundcraft_model::{ChannelFormat, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind}; +/// Returns the track command specifications exposed to UI and automation clients. pub fn specs() -> Vec { vec![ cmd!( @@ -14,7 +15,7 @@ pub fn specs() -> Vec { "New...", ["Track"], Some("Cmd+Shift+N"), - "{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id}", + "{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id, rows?: [objects with the same fields] (1–128 tracks total)}", always, new_track ), @@ -231,7 +232,37 @@ pub fn specs() -> Vec { ] } +/// Creates either one legacy row or several validated rows as one command. fn new_track(e: &mut Engine, p: &Value) -> Result { + let ids = if let Some(rows) = p.get("rows") { + let rows = rows.as_array().ok_or_else(|| bad("track.new", "`rows` must be an array"))?; + if rows.is_empty() || rows.len() > 128 { + return Err(bad("track.new", "`rows` must contain 1–128 rows")); + } + let mut total = 0_i64; + for row in rows { + if !row.is_object() || row.get("rows").is_some() { + return Err(bad("track.new", "each row must be an object without nested rows")); + } + total = total.saturating_add(i64_or(row, "count", 1).clamp(1, 128)); + } + if total > 128 { + return Err(bad("track.new", "a single creation is limited to 128 tracks")); + } + let mut ids = Vec::new(); + for row in rows { + ids.extend(new_track_row(e, row)?); + } + ids + } else { + new_track_row(e, p)? + }; + e.session_mut().edit.selected_tracks = ids.clone(); + Ok(json!({"tracks": ids})) +} + +/// All rows run inside one command, so failures roll back and Undo covers the whole creation. +fn new_track_row(e: &mut Engine, p: &Value) -> Result> { let count = i64_or(p, "count", 1).clamp(1, 128); let kind = match str_param(p, "kind") { Some(k) => TrackKind::from_id(k).ok_or_else(|| bad("track.new", format!("unknown kind `{k}`")))?, @@ -271,8 +302,7 @@ fn new_track(e: &mut Engine, p: &Value) -> Result { } ids.push(id); } - s.edit.selected_tracks = ids.clone(); - Ok(json!({"tracks": ids})) + Ok(ids) } fn group(e: &mut Engine, p: &Value) -> Result { @@ -590,6 +620,20 @@ fn owned_folder_bus(s: &Session, folder: TrackId) -> Option bool { + let route = Route::Bus(bus); + s.tracks.iter().filter(|track| !deleted.contains(&track.id)).any(|track| { + track.mixer.input == route + || track.mixer.sends.iter().flatten().any(|send| send.target == route) + || (track.mixer.output == route && !in_folder(s, track.id, folder)) + }) +} + /// Keep only the selected roots so selecting a folder and one of its children preserves the subtree. fn root_tracks(s: &Session, tracks: Vec) -> Vec { tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect() @@ -708,7 +752,11 @@ fn delete(e: &mut Engine, p: &Value) -> Result { .filter_map(|folder| { let bus = owned_folder_bus(s, *folder)?; let parent = surviving_parent(s, &tracks, *folder); - Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main))) + let replacement = parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main); + // A bus that surviving tracks route through is shared, not the + // folder's own: keep it, and every route to it, when the folder + // goes (#914). + (!bus_used_beyond_folder(s, bus, &tracks, *folder)).then_some((bus, replacement)) }) .collect(); // Promote children of deleted folders and repair only routes that used the deleted @@ -1129,6 +1177,26 @@ mod tests { assert!(e.session().bus(outer_bus).is_some(), "the surviving parent folder's bus must remain"); } + #[test] + fn deleting_a_folder_keeps_a_bus_other_tracks_still_use() { + let mut e = crate::demo::demo_engine(); + // #914: the demo session's Verb bus is shared: an aux takes it as its input + // and several tracks send to it. Point a new folder's input at it, the way + // `track.input` or a session file can, then delete the folder. + let verb = e.session().bus_by_name("Verb").unwrap().id; + let folder = e.execute("track.new", &json!({"kind": "folder", "name": "Shared"})).unwrap(); + let folder = TrackId(folder["tracks"][0].as_u64().unwrap()); + e.execute("track.input", &json!({"tracks": [folder.0], "input": "Verb"})).unwrap(); + assert_eq!(e.session().track(folder).unwrap().mixer.input, Route::Bus(verb)); + e.execute("track.delete", &json!({"tracks": [folder.0]})).unwrap(); + assert!(e.session().track(folder).is_none(), "the folder itself is deleted"); + assert!(e.session().bus(verb).is_some(), "a bus other tracks route through must survive"); + let aux = e.session().track_by_name("Verb").unwrap(); + assert_eq!(aux.mixer.input, Route::Bus(verb), "the aux input must keep the bus"); + let snare = e.session().track_by_name("Snare").unwrap(); + assert!(snare.mixer.sends.iter().flatten().any(|send| send.target == Route::Bus(verb)), "sends to the shared bus must survive"); + } + #[test] fn heights_take_points_and_step_through_the_presets() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/cmd/transport.rs b/crates/engine/src/cmd/transport.rs index 50827802..7246265e 100644 --- a/crates/engine/src/cmd/transport.rs +++ b/crates/engine/src/cmd/transport.rs @@ -9,9 +9,10 @@ pub fn specs() -> Vec { vec![ cmd!(noundo "transport.play", "Play", [], Some("Space"), "{from?: position}", always, |e, p| { if let Some(at) = position_param(e, "transport.play", p, "from")? { - e.transport_requests.push(TransportRequest::Locate(at.max(0))); + e.transport_requests.push(TransportRequest::PlayFrom(at.max(0))); + } else { + e.transport_requests.push(TransportRequest::Play); } - e.transport_requests.push(TransportRequest::Play); Ok(json!({})) }), cmd!(noundo "transport.stop", "Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Stop); Ok(json!({})) }), diff --git a/crates/engine/src/cmd/zoom_toggle.rs b/crates/engine/src/cmd/zoom_toggle.rs index 24781759..2735133d 100644 --- a/crates/engine/src/cmd/zoom_toggle.rs +++ b/crates/engine/src/cmd/zoom_toggle.rs @@ -1,4 +1,5 @@ -//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. +//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. Cancel Zoom Toggle turns it off +//! and keeps the current view. use super::{CommandSpec, always, f64_or}; use soundcraft_model::{TrackHeight, ZoomToggle}; use soundcraft_time::MAX_POSITION; @@ -7,7 +8,16 @@ use crate::{Engine, Result, cmd}; use serde_json::{Value, json}; pub fn specs() -> Vec { - 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 { + 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 { @@ -73,6 +83,24 @@ mod tests { assert_eq!(view(&e), (500.0, 1000, vec![Medium, Fit(299), Medium])); } + #[test] + fn cancel_keeps_the_zoomed_view_and_the_next_toggle_zooms_in_again() { + let mut e = engine(json!({"tracks": ["Audio 1", "Audio 3"], "start": 48_000, "end": 96_000})); + e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(); + let zoomed = view(&e); + assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": true})); + assert_eq!(e.session().edit.zoom_toggle, None); + assert_eq!(view(&e), zoomed, "cancel keeps the view Zoom Toggle zoomed in to"); + assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": false})); + assert_eq!(view(&e), zoomed); + e.execute("view.zoom_set", &json!({"samples_per_px": 200.0})).unwrap(); + assert_eq!(e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(), json!({"on": true})); + e.execute("view.zoom_toggle", &json!({})).unwrap(); + assert_eq!(view(&e), (200.0, 48_000, vec![Fit(249), Medium, Fit(249)]), "the toggle goes back to the view it saw after the cancel"); + let spec = crate::cmd::command_specs().iter().find(|c| c.id == "view.zoom_toggle_cancel").unwrap(); + assert_eq!(spec.shortcut, Some("Alt+Shift+E")); + } + #[test] fn hostile_sizes_and_stored_views_stay_in_range() { let mut e = engine(json!({"tracks": ["Audio 1"], "start": 0, "end": MAX_POSITION})); diff --git a/crates/engine/src/dirty.rs b/crates/engine/src/dirty.rs new file mode 100644 index 00000000..ea98825f --- /dev/null +++ b/crates/engine/src/dirty.rs @@ -0,0 +1,265 @@ +//! Borrowed document equality for the saved-state indicator. + +use soundcraft_model::{EditState, Session}; + +/// Equal apart from the selection, playhead and zoom, without copying document data. +pub(super) fn same_document(saved: &Session, doc: &Session) -> bool { + document_values(saved, &saved.edit) == document_values(doc, &saved.edit) +} + +fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl PartialEq + 'a { + // No `..`: adding a document field must also decide how it affects saved-state equality. + let Session { + format_version, + name, + sample_rate, + bit_depth, + frame_rate, + timecode_start, + tempo, + key_signatures, + tracks, + sources, + videos, + busses, + outputs, + markers, + groups, + auto_names, + edit, + next_id, + comments, + midi_originals, + pool, + folder, + } = s; + // Tuples implement PartialEq up to twelve elements. These contain only borrowed fields. + ( + (format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, tracks, sources, videos, busses), + (outputs, markers, groups, auto_names, edit_values(edit, navigation), next_id, comments, midi_originals, pool, folder), + ) +} + +fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl PartialEq + 'a { + let EditState { + tool, + edit_mode, + selection: _, + selected_tracks: _, + selected_clips: _, + selected_sources, + playhead: _, + grid, + nudge, + grid_lines, + main_counter, + sub_counter, + zoom: _, + zoom_toggle, + rulers, + link_timeline_edit, + link_track_edit, + insertion_follows_playback, + loop_playback, + pre_post_roll, + pre_roll, + post_roll, + tab_to_transient, + automation_follows_edit, + markers_follow_edit, + layered_editing, + mirrored_midi, + click, + countoff, + countoff_bars, + midi_merge, + record_mode, + loop_record, + quickpunch, + scrolling, + solo_mode, + pre_fader_metering, + delay_compensation, + keyboard_focus, + timeline_selection: _, + flags, + values, + } = edit; + // Borrow the saved navigation on both sides instead of overwriting a document clone. + // Keeping these values in the comparison also preserves PartialEq for non-finite zooms. + ( + ( + tool, + edit_mode, + &navigation.selection, + &navigation.selected_tracks, + &navigation.selected_clips, + selected_sources, + &navigation.playhead, + grid, + nudge, + grid_lines, + main_counter, + sub_counter, + ), + ( + &navigation.zoom, + zoom_toggle, + rulers, + link_timeline_edit, + link_track_edit, + insertion_follows_playback, + loop_playback, + pre_post_roll, + pre_roll, + post_roll, + tab_to_transient, + automation_follows_edit, + ), + ( + markers_follow_edit, + layered_editing, + mirrored_midi, + click, + countoff, + countoff_bars, + midi_merge, + record_mode, + loop_record, + quickpunch, + scrolling, + solo_mode, + ), + (pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_model::{ChannelFormat, Clip, ClipId, SourceId, TrackId, TrackKind, ZoomToggle}; + use soundcraft_time::{Range, TimeFormat}; + use std::sync::Arc; + + // The previous comparison is an oracle for preserving the saved-state policy. + fn previous_same_document(saved: &Session, doc: &Session) -> bool { + let mut doc = doc.clone(); + let (from, to) = (&saved.edit, &mut doc.edit); + to.selection = from.selection; + to.timeline_selection = from.timeline_selection; + to.selected_tracks.clone_from(&from.selected_tracks); + to.selected_clips.clone_from(&from.selected_clips); + to.playhead = from.playhead; + to.zoom = from.zoom; + *saved == doc + } + + fn check(saved: &Session, doc: &Session, expected: bool, field: &str) { + assert_eq!(same_document(saved, doc), expected, "{field}"); + assert_eq!(same_document(doc, saved), expected, "{field}, reversed"); + assert_eq!(same_document(saved, doc), previous_same_document(saved, doc), "{field}, previous policy"); + } + + #[test] + fn navigation_fields_are_ignored() { + let saved = Session::default(); + let mut doc = saved.clone(); + doc.edit.selection = Range::new(100, 200); + doc.edit.timeline_selection = Range::new(300, 400); + doc.edit.selected_tracks = vec![TrackId(50)]; + doc.edit.selected_clips = vec![ClipId(60)]; + doc.edit.playhead = 500; + doc.edit.zoom.samples_per_px = 64.0; + doc.edit.zoom.scroll = 600; + doc.edit.zoom.waveform_zoom = 2.0; + doc.edit.zoom.presets = [1.0; 5]; + check(&saved, &doc, true, "navigation"); + } + + #[test] + fn persisted_edit_settings_still_change_document_equality() { + let saved = Session::default(); + let edits: &[(&str, fn(&mut EditState))] = &[ + ("tool", |e| e.tool = soundcraft_model::Tool::Grabber), + ("edit_mode", |e| e.edit_mode = soundcraft_model::EditMode::Grid), + ("selected_sources", |e| e.selected_sources.push(SourceId(1))), + ("grid", |e| e.grid = soundcraft_time::GridValue::Seconds(2.0)), + ("nudge", |e| e.nudge = soundcraft_time::GridValue::Seconds(2.0)), + ("grid_lines", |e| e.grid_lines = !e.grid_lines), + ("main_counter", |e| e.main_counter = TimeFormat::Samples), + ("sub_counter", |e| e.sub_counter = None), + ("zoom_toggle", |e| e.zoom_toggle = Some(ZoomToggle { samples_per_px: 64.0, scroll: 0, heights: Vec::new() })), + ("rulers", |e| e.rulers.clear()), + ("link_timeline_edit", |e| e.link_timeline_edit = !e.link_timeline_edit), + ("link_track_edit", |e| e.link_track_edit = !e.link_track_edit), + ("insertion_follows_playback", |e| e.insertion_follows_playback = !e.insertion_follows_playback), + ("loop_playback", |e| e.loop_playback = !e.loop_playback), + ("pre_post_roll", |e| e.pre_post_roll = !e.pre_post_roll), + ("pre_roll", |e| e.pre_roll += 1), + ("post_roll", |e| e.post_roll += 1), + ("tab_to_transient", |e| e.tab_to_transient = !e.tab_to_transient), + ("automation_follows_edit", |e| e.automation_follows_edit = !e.automation_follows_edit), + ("markers_follow_edit", |e| e.markers_follow_edit = !e.markers_follow_edit), + ("layered_editing", |e| e.layered_editing = !e.layered_editing), + ("mirrored_midi", |e| e.mirrored_midi = !e.mirrored_midi), + ("click", |e| e.click = !e.click), + ("countoff", |e| e.countoff = !e.countoff), + ("countoff_bars", |e| e.countoff_bars += 1), + ("midi_merge", |e| e.midi_merge = !e.midi_merge), + ("record_mode", |e| e.record_mode = "destructive".into()), + ("loop_record", |e| e.loop_record = !e.loop_record), + ("quickpunch", |e| e.quickpunch = !e.quickpunch), + ("scrolling", |e| e.scrolling = "continuous".into()), + ("solo_mode", |e| e.solo_mode = "afl".into()), + ("pre_fader_metering", |e| e.pre_fader_metering = !e.pre_fader_metering), + ("delay_compensation", |e| e.delay_compensation = !e.delay_compensation), + ("keyboard_focus", |e| e.keyboard_focus = "midi".into()), + ("flags", |e| { + e.flags.insert("test.flag".into()); + }), + ("values", |e| { + e.values.insert("test.value".into(), 0.5); + }), + ]; + for &(field, edit) in edits { + let mut doc = saved.clone(); + edit(&mut doc.edit); + check(&saved, &doc, false, field); + } + } + + #[test] + fn track_and_media_changes_still_change_document_equality() { + let mut saved = Session::default(); + let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let clip = Clip::audio(saved.new_clip_id(), "Clip", SourceId(900), 0, 0, 100); + saved.track_mut(track).unwrap().playlist_mut().unwrap().clips.push(clip); + let audio = + Arc::new(soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 100]] })); + saved.pool.insert(SourceId(900), audio); + check(&saved, &saved.clone(), true, "shared decoded audio"); + let mut doc = saved.clone(); + doc.track_mut(track).unwrap().playlist_mut().unwrap().clips[0].gain_db = -6.0; + check(&saved, &doc, false, "clip gain"); + let mut doc = saved.clone(); + doc.pool.remove(SourceId(900)); + check(&saved, &doc, false, "decoded audio removed"); + let mut doc = saved.clone(); + doc.folder = Some(std::path::PathBuf::from("session")); + check(&saved, &doc, false, "session folder"); + let mut doc = saved.clone(); + doc.edit.values.insert("test.nan".into(), f64::NAN); + assert_eq!(same_document(&doc, &doc), previous_same_document(&doc, &doc), "NaN keeps the previous PartialEq behavior"); + } + + #[test] + fn non_finite_saved_zoom_keeps_the_previous_partial_eq_behavior() { + let mut saved = Session::default(); + let doc = saved.clone(); + saved.edit.zoom.waveform_zoom = f32::NAN; + assert!(!same_document(&saved, &doc)); + assert!(same_document(&doc, &saved)); + assert_eq!(same_document(&saved, &doc), previous_same_document(&saved, &doc)); + assert_eq!(same_document(&doc, &saved), previous_same_document(&doc, &saved)); + } +} diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index beed1e8f..9fdb49f0 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -177,6 +177,26 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) removed } +/// Remove the clips `ids` from every track. In Shuffle the later clips on each track close the holes the movable +/// ones leave, as a Shuffle move lifts them (see [`lift_plan`]), so a crossfade partner keeps its material. Returns +/// how many clips went. +pub fn remove_clips(s: &mut Session, ids: &[ClipId], shuffle: bool) -> usize { + let ids: HashSet = ids.iter().copied().collect(); + let mut n = 0; + for track in s.tracks.iter().map(|t| t.id).collect::>() { + if shuffle { + n += lift_all(s, track, &ids).len(); + } + // Locked clips don't lift; they go without closing up, as everything does outside Shuffle. + if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { + let before = pl.clips.len(); + pl.clips.retain(|c| !ids.contains(&c.id)); + n += before - pl.clips.len(); + } + } + n +} + /// Copy the material inside `range` (clips trimmed to the range), relative to `range.start`. pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec { let t = TimeCtx::of(s); @@ -238,13 +258,16 @@ pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], lengt make_room(s, track, at, length)?; } let mut ids = Vec::new(); + let mut placed = Vec::new(); for c in clips { let mut nc = c.clone(); nc.id = s.new_clip_id(); nc.start += at; ids.push(nc.id); - place_clip(s, track, nc); + placed.push((track, nc)); } + // Clear every destination first, so pasted clips that overlap (crossfades) keep each other. + put_clips(s, placed); Ok(ids) } @@ -523,8 +546,10 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac } } let n = moving.len(); + // Limit the shift once, so the group stops at zero together and keeps its spacing. + let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0)); let placed = moving.into_iter().map(|(tid, mut c)| { - c.start = c.start.saturating_add(delta).max(0); + c.start = c.start.saturating_add(delta); (lands(dest, tid), c) }); put_clips(s, placed.collect()); @@ -794,8 +819,11 @@ pub fn trim_clip(s: &mut Session, id: ClipId, start: Option, end: Optio lo = lo.max(0); } let file = |c: &Clip| { - let start = c.start.saturating_sub(c.source_offset()); - material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(n).max(c.end()))) + // The mixer plays from `offset + rel / stretch`, so file samples span `stretch` times as many timeline samples. + let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 }; + let scale = |n: Samples| if (stretch - 1.0).abs() >= 1e-9 { (n as f64 * stretch).round() as Samples } else { n }; + let start = c.start.saturating_sub(scale(c.source_offset())); + material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(scale(n)).max(c.end()))) }; let t = TimeCtx::of(s); let mut c = old.clone(); diff --git a/crates/engine/src/edit/tests.rs b/crates/engine/src/edit/tests.rs index 95866b93..cd84d6f0 100644 --- a/crates/engine/src/edit/tests.rs +++ b/crates/engine/src/edit/tests.rs @@ -159,6 +159,40 @@ fn clear_slip_and_shuffle() { assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 2000, 3000)]); } +#[test] +fn remove_clips_closes_each_gap_in_shuffle_only() { + let split = |shuffle| { + let (mut s, t) = session_with_clip(); + separate_at(&mut s, t, 2000); + separate_at(&mut s, t, 3000); + separate_at(&mut s, t, 4000); + let ids: Vec = 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 = 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::>() + }; + // Crossfaded with the clip before: the hole starts where that one ends. + assert_eq!(remove_second(&[(0, 1050), (950, 2000), (2000, 3000)]), vec![(0, 1050), (1050, 2050)]); + // Crossfaded with the clip after: the hole ends where that one starts. + assert_eq!(remove_second(&[(0, 1000), (1000, 2050), (1950, 3000), (4000, 5000)]), vec![(0, 1000), (1000, 2050), (3050, 4050)]); +} + #[test] fn copy_paste_overwrites() { let (mut s, t) = session_with_clip(); @@ -343,3 +377,60 @@ fn delete_fades_leaves_unrelated_overlaps_alone() { let c = s.track(t).unwrap().clips(); assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100)); } + +#[test] +fn paste_keeps_crossfade_overlap() { + let (mut s, t) = session_with_clip(); + let (a, b) = (s.new_clip_id(), s.new_clip_id()); + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips.clear(); + let mut ca = Clip::audio(a, "a", SourceId(500), 0, 1000, 2000); + ca.fade_out = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 }; + let mut cb = Clip::audio(b, "b", SourceId(500), 0, 2500, 2500); + cb.fade_in = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 }; + pl.clips.push(ca); + pl.clips.push(cb); + pl.sort(); + } + let copied = copy_range(&s, t, Range::new(0, 6000)); + paste(&mut s, t, 20_000, &copied, 6000, false).unwrap(); + let pasted: Vec<_> = + s.track(t).unwrap().clips().iter().filter(|c| c.start >= 20_000).map(|c| (c.start, c.length, c.fade_out.len, c.fade_in.len)).collect(); + assert_eq!(pasted, vec![(21_000, 2000, 500, 0), (22_500, 2500, 0, 500)]); +} + +#[test] +fn trim_clip_bounds_stretched_clip_to_its_file() { + for (stretch, length, want) in [(0.5, 5000, 5000), (2.0, 1000, 20_000)] { + let (mut s, t) = session_with_clip(); + let id = { + let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap(); + c.stretch = stretch; + c.start = 0; + c.length = length; + if let ClipContent::Audio { offset, .. } = &mut c.content { + *offset = 0; + } + c.id + }; + let c = trim_clip(&mut s, id, None, Some(99_999), false).unwrap(); + assert_eq!(c.end(), want, "stretch {stretch}"); + } +} + +#[test] +fn trim_clip_start_bound_scales_the_offset_by_stretch() { + let (mut s, t) = session_with_clip(); + let id = { + let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap(); + c.stretch = 2.0; + c.start = 10_000; + c.length = 1000; + if let ClipContent::Audio { offset, .. } = &mut c.content { + *offset = 1000; + } + c.id + }; + let c = trim_clip(&mut s, id, Some(0), None, false).unwrap(); + assert_eq!(c.start, 8000); +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 9084a25b..6869c1e8 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -115,7 +115,7 @@ pub fn session_text(e: &Engine) -> String { out.push_str(&format!("SESSION NAME:\t{}\nSAMPLE RATE:\t{}\nBIT DEPTH:\t{:?}\nTIMECODE FORMAT:\t{}\n# OF AUDIO TRACKS:\t{}\n# OF AUDIO CLIPS:\t{}\n# OF AUDIO FILES:\t{}\n\n", s.name, s.sample_rate.hz(), s.bit_depth, s.frame_rate.label(), s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).count(), - s.tracks.iter().map(|t| t.clips().len()).sum::(), s.sources.len())); + s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum::(), s.sources.len())); out.push_str("F I L E S I N S E S S I O N\nFilename\tLocation\n"); for src in &s.sources { out.push_str(&format!("{}\t{}\n", src.name, src.path)); @@ -156,6 +156,19 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn session_text_counts_only_audio_track_clips() { + let mut e = Engine::default(); + let t = e.execute("track.new", &json!({"kind": "midi"})).unwrap()["tracks"][0].clone(); + e.execute("midi.new_clip", &json!({"track": t, "start": 0, "length": 48000})).unwrap(); + let text = session_text(&e); + assert!(text.contains("# OF AUDIO CLIPS:\t0\n"), "{text}"); + + let demo = crate::demo::demo_engine(); + let n: usize = demo.session().tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum(); + assert!(session_text(&demo).contains(&format!("# OF AUDIO CLIPS:\t{n}\n"))); + } + #[test] fn sources_report_whether_audio_is_loaded() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index c457dcf2..60d9a323 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -598,6 +598,7 @@ fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> { let mut src = std::fs::File::open(from)?; let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?; if let Err(err) = std::io::copy(&mut src, &mut dst) { + #[cfg(not(target_arch = "wasm32"))] drop(dst); let _ = std::fs::remove_file(to); return Err(err); diff --git a/crates/engine/src/io/bundle.rs b/crates/engine/src/io/bundle.rs index 27bad519..cfe6e0af 100644 --- a/crates/engine/src/io/bundle.rs +++ b/crates/engine/src/io/bundle.rs @@ -1,6 +1,6 @@ use super::render::sanitize_name; use super::session::decode_source; -use super::*; +use super::{Arc, EncodeOptions, Engine, EngineError, FileFormat, OsStr, Path, Result, Session, SourceAudio, read_bundle, write_file}; // ─── Hosts without a filesystem (the browser) ───────────────────────────────────────────────── diff --git a/crates/engine/src/io/import.rs b/crates/engine/src/io/import.rs index f34b5f29..a2011def 100644 --- a/crates/engine/src/io/import.rs +++ b/crates/engine/src/io/import.rs @@ -1,5 +1,8 @@ use super::render::sanitize_name; -use super::*; +use super::{ + Arc, AudioBuffer, ChannelFormat, Clip, Engine, EngineError, FileFormat, OsStr, Path, PathBuf, Result, Samples, Session, Source, SourceAudio, + SourceId, TrackId, TrackKind, Value, file_found, file_key, json, read_file, stored_path, +}; /// Largest import we accept (samples per channel × channels), ~4 GiB of f32. pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30; diff --git a/crates/engine/src/io/midi.rs b/crates/engine/src/io/midi.rs index 405f1282..7ef4c0ee 100644 --- a/crates/engine/src/io/midi.rs +++ b/crates/engine/src/io/midi.rs @@ -1,18 +1,34 @@ -use super::*; +use super::{ChannelFormat, Clip, ClipContent, Engine, EngineError, Result, Samples, TrackId, TrackKind}; /// Most meter changes a MIDI file may bring into the tempo map; scores have a handful. pub(super) const MAX_MIDI_METERS: usize = 10_000; +/// Most tempo changes a MIDI file may bring into the tempo map: conversions walk the tempo events. +pub(super) const MAX_MIDI_TEMPOS: usize = 10_000; +/// Most synth tracks a MIDI file may create in the session. +pub(super) const MAX_MIDI_TRACKS: usize = 512; /// Import a Standard MIDI File as MIDI tracks (one per SMF track). pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result> { let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?; - let s = e.session_mut(); if tempo_map { // Each meter is placed by walking the ones before it. if smf.meters.len() > MAX_MIDI_METERS { return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_METERS} meter changes"))); } + if smf.tempos.len() > MAX_MIDI_TEMPOS { + return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TEMPOS} tempo changes"))); + } + } + let tracks_to_import = smf.tracks.iter().filter(|tr| !tr.sequence.notes.is_empty() || !tr.sequence.ctrls.is_empty()).count(); + if tracks_to_import > MAX_MIDI_TRACKS { + return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TRACKS} tracks"))); + } + let s = e.session_mut(); + if tempo_map { for (tick, bpm) in &smf.tempos { + if s.tempo.tempo_at_tick(*tick) == *bpm { + continue; + } let _ = s.tempo.set_tempo(*tick, *bpm); } for (tick, n, d) in &smf.meters { @@ -66,15 +82,37 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> { for c in t.clips() { if let ClipContent::Midi { sequence } = &c.content { let base = s.tempo.samples_to_ticks(c.start, s.sample_rate); - for n in &sequence.notes { + // Only what plays: notes starting inside the clip, cut at its end. + let clip_end = s.tempo.samples_to_ticks(c.end(), s.sample_rate); + for n in sequence.notes.iter().filter(|n| n.start.saturating_add(base) < clip_end) { let mut n = *n; n.start = n.start.saturating_add(base); + n.length = n.length.min(clip_end - n.start).max(1); seq.notes.push(n); } - for ev in &sequence.ctrls { + let mut kept: Vec = Vec::new(); + for ev in sequence.ctrls.iter().filter(|ev| ev.tick.saturating_add(base) < clip_end) { let mut ev = *ev; ev.tick = ev.tick.saturating_add(base); - seq.ctrls.push(ev); + kept.push(ev); + } + // A sustain still held at the clip end is released there. + let mut release: Vec = Vec::new(); + for ev in &kept { + if ev.kind != soundcraft_midi::CtrlKind::Cc(64) { + continue; + } + match release.iter_mut().find(|r| r.channel == ev.channel) { + Some(r) if ev.tick >= r.tick => *r = *ev, + Some(_) => {} + None => release.push(*ev), + } + } + seq.ctrls.append(&mut kept); + for mut r in release.into_iter().filter(|r| r.value >= 64) { + r.tick = clip_end; + r.value = 0; + seq.ctrls.push(r); } } } diff --git a/crates/engine/src/io/record.rs b/crates/engine/src/io/record.rs index ece38abf..32e20420 100644 --- a/crates/engine/src/io/record.rs +++ b/crates/engine/src/io/record.rs @@ -1,7 +1,7 @@ use super::import::add_source; #[cfg(test)] use super::session::{save_copy, save_session}; -use super::*; +use super::{Clip, Engine, EngineError, FileFormat, OsStr, PathBuf, Range, Samples, TrackId, read_file}; /// A recording's plan, made when it starts: each record-enabled track's take file, where the /// takes start on the timeline, the loop length they split into when loop recording, and the @@ -117,6 +117,7 @@ pub fn live_span(start: Samples, pass: Option, punch: Option, le #[cfg(test)] mod record_tests { + use super::super::{AudioBuffer, Path, plan_takes}; use super::*; use serde_json::json; diff --git a/crates/engine/src/io/render.rs b/crates/engine/src/io/render.rs index cb8b42c5..dafa8897 100644 --- a/crates/engine/src/io/render.rs +++ b/crates/engine/src/io/render.rs @@ -1,7 +1,7 @@ use super::import::add_source; #[cfg(test)] -use super::midi::{MAX_MIDI_METERS, import_midi_bytes}; -use super::*; +use super::midi::{MAX_MIDI_METERS, MAX_MIDI_TEMPOS, MAX_MIDI_TRACKS, import_midi_bytes}; +use super::{AudioBuffer, Clip, ClipContent, Engine, EngineError, FileFormat, Range, Result, Samples, Session, TrackId, TrackKind}; /// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the /// whole range, silence included; a range too long to render is refused rather than shortened. @@ -196,6 +196,77 @@ mod name_tests { assert!(err.to_string().contains("meter changes"), "{err}"); } + /// A MIDI file with more tempo changes than MAX_MIDI_TEMPOS is refused without mutating the session. + #[test] + fn midi_with_more_tempo_changes_than_performances_have_is_refused() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let smf = |n: usize| soundcraft_midi::Smf { + tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence: sequence.clone() }], + tempos: (0..n as i64).map(|beat| (beat * 960, 100.0 + (beat % 40) as f64)).collect(), + ..Default::default() + }; + let mut e = Engine::default(); + let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS + 1)).unwrap(), 0, true).unwrap_err(); + assert!(err.to_string().contains("tempo changes"), "{err}"); + assert_eq!((e.session().tempo.tempos().len(), e.session().tracks.len()), (1, 0), "nothing is imported"); + import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS)).unwrap(), 0, true).unwrap(); + assert_eq!(e.session().tempo.tempos().len(), MAX_MIDI_TEMPOS); + } + + /// A MIDI file with more tracks than MAX_MIDI_TRACKS is refused without mutating the session. + #[test] + fn midi_with_more_tracks_than_supported_is_refused() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let smf = |n: usize| soundcraft_midi::Smf { + tracks: (0..n).map(|i| soundcraft_midi::SmfTrack { name: format!("Tr {i}"), sequence: sequence.clone() }).collect(), + ..Default::default() + }; + let mut e = Engine::default(); + let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS + 1)).unwrap(), 0, false).unwrap_err(); + assert!(err.to_string().contains("tracks"), "{err}"); + assert_eq!(e.session().tracks.len(), 0, "no tracks imported"); + import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS)).unwrap(), 0, false).unwrap(); + assert_eq!(e.session().tracks.len(), MAX_MIDI_TRACKS); + } + + /// Consecutive equal tempo events in a MIDI file are collapsed to actual tempo changes. + #[test] + fn midi_collapses_equal_consecutive_tempos() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let smf = soundcraft_midi::Smf { + tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }], + tempos: vec![(0, 120.0), (960, 120.0), (1920, 140.0), (2880, 140.0), (3840, 100.0)], + ..Default::default() + }; + let mut e = Engine::default(); + import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap(); + let tempos = e.session().tempo.tempos(); + assert_eq!(tempos.len(), 3); + assert!((tempos[0].bpm - 120.0).abs() < 1e-3); + assert!((tempos[1].bpm - 140.0).abs() < 1e-3); + assert!((tempos[2].bpm - 100.0).abs() < 1e-3); + } + + /// A MIDI file replaces an existing session tempo event even if the MIDI file has consecutive equal tempos. + #[test] + fn midi_import_replaces_differing_existing_session_tempo() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let smf = soundcraft_midi::Smf { + tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }], + tempos: vec![(0, 120.0), (960, 120.0)], + ..Default::default() + }; + let mut e = Engine::default(); + e.session_mut().tempo.set_tempo(960, 80.0).unwrap(); + assert_eq!(e.session().tempo.tempo_at_tick(960), 80.0); + import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap(); + assert_eq!(e.session().tempo.tempo_at_tick(960), 120.0); + } + #[test] fn new_sources_take_the_first_free_file_name() { let mut s = Session::default(); diff --git a/crates/engine/src/io/session.rs b/crates/engine/src/io/session.rs index 0c934f58..8fcd47f8 100644 --- a/crates/engine/src/io/session.rs +++ b/crates/engine/src/io/session.rs @@ -2,7 +2,9 @@ use super::bundle::save_session_bundle; #[cfg(test)] use super::import::add_source; use super::import::{MAX_IMPORT_SAMPLES, at_rate, relink_videos}; -use super::*; +use super::{ + Arc, Engine, EngineError, OsStr, Path, PathBuf, Result, Session, SourceAudio, file_key, read_file, read_text, stored_path, write_session, +}; /// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written /// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written. @@ -92,7 +94,9 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std: #[cfg(test)] mod save_tests { + use super::super::{AudioBuffer, ChannelFormat, Clip, Engine, FileFormat, Range, TrackKind, back_up, copy_new}; use super::*; + use serde_json::json; #[test] fn autosave_copy_leaves_document_untouched_and_reuses_audio() { @@ -315,7 +319,9 @@ mod save_tests { #[cfg(test)] mod open_tests { + use super::super::{AudioBuffer, ChannelFormat, Clip, EncodeOptions, Engine, FileFormat, SourceId, TrackKind}; use super::*; + use serde_json::json; #[test] fn a_file_named_by_many_sources_is_decoded_once() { @@ -428,7 +434,9 @@ mod open_tests { #[cfg(all(test, unix))] mod pipe_tests { + use super::super::{AudioBuffer, FileFormat}; use super::*; + use serde_json::json; #[test] fn a_pipe_named_as_media_is_refused_not_waited_on() { diff --git a/crates/engine/src/io/strip.rs b/crates/engine/src/io/strip.rs index 6591b873..ec808241 100644 --- a/crates/engine/src/io/strip.rs +++ b/crates/engine/src/io/strip.rs @@ -1,6 +1,6 @@ #[cfg(test)] use super::import::add_source; -use super::*; +use super::{Arc, AtomicBool, Clip, EngineError, Ordering, Range, Result, Samples, Session, TrackId}; /// Limit a range used for rendering to the track's material (plus a second), so a huge /// selection never renders hours of silence. @@ -244,7 +244,9 @@ fn non_silent( #[cfg(test)] mod strip_tests { + use super::super::{AudioBuffer, Engine, FileFormat, Value}; use super::*; + use serde_json::json; /// A stereo track with `length`-long clips at `starts`, each playing a second of steady tone. fn tones(starts: &[Samples], length: Samples) -> (Engine, TrackId) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b0ec0939..40c3cee9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,7 @@ pub mod catalog_more; pub mod clip_group_file; pub mod cmd; pub mod demo; +mod dirty; pub mod edit; pub mod file_access; pub mod inspect; @@ -23,9 +24,17 @@ use soundcraft_time::Samples; use std::cell::Cell; use std::sync::Arc; +use dirty::same_document; + pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use soundcraft_model as model; +/// Upper bound on journaled commands, so a long session cannot grow memory without limit. +const MAX_JOURNAL_ENTRIES: usize = 10_000; +/// Upper bound on a single journaled entry's params (serialized), so junk fields from MCP or the +/// control channel cannot keep a huge blob alive for the life of the process. +const MAX_JOURNAL_ENTRY_BYTES: usize = 64 * 1024; // 64 KiB + /// Shuts the third-party plugin hosts down, running the module exit of every plugin binary they /// loaded. Call it at the end of `main`, once every plugin instance (mixer, player) is gone: a /// plugin that has been used can crash when its module exit only runs from an exit handler. @@ -75,6 +84,8 @@ pub enum TransportRequest { /// Stop the old session's transport without applying stop-time edits to the new document. ResetSession, Play, + /// Start playing at this position (or move there if already playing), ignoring the selection. + PlayFrom(Samples), Stop, TogglePlay, Record, @@ -173,8 +184,9 @@ pub struct Engine { pub clipboard: Clipboard, pub transport_requests: Vec, pub transport: TransportStatus, - /// Command journal (id, params) for macro recording / agents. - pub journal: Vec<(String, Value)>, + /// Command journal (id, params) for macro recording / agents. Ring buffer: capped at + /// [`MAX_JOURNAL_ENTRIES`] entries of up to [`MAX_JOURNAL_ENTRY_BYTES`] params each. + pub journal: std::collections::VecDeque<(String, Value)>, /// Path of the session file, if saved. pub path: Option, /// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it). @@ -216,7 +228,7 @@ impl Engine { clipboard: Clipboard::default(), transport_requests: Vec::new(), transport: TransportStatus::default(), - journal: Vec::new(), + journal: std::collections::VecDeque::new(), path: None, revision: 1, dirty: false, @@ -419,7 +431,7 @@ impl Engine { self.push_undo(spec.label, before); } if spec.journal { - self.journal.push((id.to_string(), params.clone())); + self.journal_append(id, params); } Ok(v) } @@ -486,6 +498,16 @@ impl Engine { self.merge_key = None; } + /// Append a journaled command, clamping its params to [`MAX_JOURNAL_ENTRY_BYTES`] and the + /// buffer to [`MAX_JOURNAL_ENTRIES`] entries (oldest are dropped). + fn journal_append(&mut self, id: &str, params: &Value) { + let clamped = (id.to_string(), journal_params(params)); + self.journal.push_back(clamped); + if self.journal.len() > MAX_JOURNAL_ENTRIES { + self.journal.pop_front(); + } + } + /// Convenience: execute with `{}` params. pub fn run(&mut self, id: &str) -> Result { self.execute(id, &json!({})) @@ -511,19 +533,6 @@ impl Engine { } } -/// Equal apart from what changes without editing: the selection, the playhead and the zoom. -fn same_document(saved: &Session, doc: &Session) -> bool { - let mut doc = doc.clone(); - let (from, to) = (&saved.edit, &mut doc.edit); - to.selection = from.selection; - to.timeline_selection = from.timeline_selection; - to.selected_tracks.clone_from(&from.selected_tracks); - to.selected_clips.clone_from(&from.selected_clips); - to.playhead = from.playhead; - to.zoom = from.zoom; - *saved == doc -} - fn install_panic_hook() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { @@ -539,6 +548,92 @@ fn panic_message(p: &(dyn std::any::Any + Send)) -> String { p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) } +/// The params to keep in the journal for a command: the value itself when it serializes within +/// [`MAX_JOURNAL_ENTRY_BYTES`], or a truncation marker when it is too large or unparseable. +/// +/// Serializes into a [`std::io::Write`] sink that fails as soon as the cap is exceeded, so an +/// oversized `params` (a multi-MiB line from MCP or the control channel) is never fully +/// serialized, cloned, or allocated just to be discarded. +fn journal_params(params: &Value) -> Value { + // Write through a bounded sink: serialization stops (WriteZero) the moment the cap is passed, + // so an oversized value costs at most ~1× the cap, not its full serialized size. + struct Bounded(Vec); + impl std::io::Write for Bounded { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let room = MAX_JOURNAL_ENTRY_BYTES.saturating_sub(self.0.len()); + if room == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap")); + } + let take = buf.len().min(room); + self.0.extend_from_slice(&buf[..take]); + if take < buf.len() { + return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap")); + } + Ok(take) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut sink = Bounded(Vec::new()); + // Fails with WriteZero as soon as the cap is exceeded, so the allocation stays bounded even + // for a multi-MiB params. + let ok = serde_json::to_writer(&mut sink, params).is_ok(); + if !ok { + return json!({"__truncated__": true}); + } + params.clone() +} + +#[cfg(test)] +mod journal_tests { + use super::*; + + #[test] + fn journal_is_bounded_to_max_entries() { + let mut e = Engine::default(); + // Calling the private append helper directly exercises the ring buffer without the cost + // of cloning the whole document on 10,000+ `track.new` calls. + for i in 0..MAX_JOURNAL_ENTRIES + 500 { + let n = format!("t{i}"); + e.journal_append("track.new", &json!({"name": n})); + } + assert_eq!(e.journal.len(), MAX_JOURNAL_ENTRIES); + // The oldest entries were dropped, the newest kept: the first remaining is the 501st pushed, + // and the newest still there. + let first = e.journal.front().map(|(_, p)| p["name"].as_str().unwrap_or_default()); + assert_eq!(first, Some("t500")); + let last = e.journal.back().map(|(_, p)| p["name"].as_str().unwrap_or_default()); + let last_name = format!("t{}", MAX_JOURNAL_ENTRIES + 499); + assert_eq!(last, Some(last_name.as_str())); + assert_eq!(e.journal.back().map(|(id, _)| id.as_str()), Some("track.new")); + } + + #[test] + fn oversized_params_are_truncated_in_the_journal() { + let big = "x".repeat(MAX_JOURNAL_ENTRY_BYTES * 4); + assert_eq!(journal_params(&json!({"name": big})), json!({"__truncated__": true})); + } + + #[test] + fn small_params_are_kept_whole() { + let p = json!({"track": "Kick", "db": -6.0}); + assert_eq!(journal_params(&p), p); + } + + #[test] + fn cap_boundary_is_exact() { + // A value just under the cap is kept whole; one just over is truncated. "Just" means the + // bounded writer made at most ~two allocations of cap size, not a full multi-MiB buffer. + // `{"n":"…"}` adds 9 bytes around the string. + let small_str = MAX_JOURNAL_ENTRY_BYTES - 64; + let small = json!({"n": "x".repeat(small_str)}); + assert_eq!(journal_params(&small), small); + let big = json!({"n": "x".repeat(MAX_JOURNAL_ENTRY_BYTES)}); + assert_eq!(journal_params(&big), json!({"__truncated__": true})); + } +} + #[cfg(test)] mod merge_tests { use super::*; @@ -607,6 +702,33 @@ mod dirty_tests { e.undo(); assert!(!e.is_dirty()); } + + #[test] + fn dirty_state_follows_save_undo_and_redo_after_navigation() { + let mut e = Engine::default(); + let track = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None); + e.mark_clean(); + e.execute("mix.volume", &json!({"track": track.0, "db": -6.0})).unwrap(); + assert!(e.is_dirty()); + // A repeated check uses the revision cache. + assert!(e.is_dirty()); + e.mark_clean(); + assert!(!e.is_dirty()); + e.execute("edit.select", &json!({"tracks": [track.0], "start": 100, "end": 200})).unwrap(); + e.execute("transport.locate", &json!({"at": 300})).unwrap(); + e.execute("view.zoom_in", &json!({})).unwrap(); + assert!(!e.is_dirty()); + assert!(e.undo()); + assert!(e.is_dirty(), "undoing away from the saved fader value is dirty"); + assert!(e.redo()); + assert!(!e.is_dirty(), "redoing to the saved fader value is clean despite navigation changes"); + e.execute("mix.volume", &json!({"track": track.0, "db": -12.0})).unwrap(); + assert!(e.is_dirty()); + assert!(e.undo()); + assert!(!e.is_dirty()); + assert!(e.redo()); + assert!(e.is_dirty()); + } } #[cfg(test)] diff --git a/crates/engine/src/score.rs b/crates/engine/src/score.rs index 847be67e..70849156 100644 --- a/crates/engine/src/score.rs +++ b/crates/engine/src/score.rs @@ -13,9 +13,26 @@ use std::fmt::Write as _; const UNIT: i64 = TICKS_PER_QUARTER / 4; /// Upper bound on engraved measures, so a hostile session cannot make a huge document. const MAX_MEASURES: usize = 2000; +/// Upper bound on output size, so a hostile session cannot make a huge document. +const MAX_OUTPUT_BYTES: usize = 256 * 1024 * 1024; // 256 MiB +/// Upper bound on items per measure, so a hostile session cannot make a huge document. +const MAX_ITEMS_PER_MEASURE: usize = 128; +/// Upper bound on pitches per chord, so a hostile session cannot make a huge document. +const MAX_PITCHES_PER_CHORD: usize = 16; +/// Upper bound on notes collected per part, before `fill` builds onsets and spans, so a hostile +/// session cannot make `layout` accumulate an unbounded note vector. +const MAX_NOTES_PER_PART: usize = 100_000; /// Note values a single notehead can show, in sixteenths (longest first). const VALUES: [u32; 8] = [16, 12, 8, 6, 4, 3, 2, 1]; +/// The output has grown past the byte budget: refuse to grow it further. +fn over_budget(buf: &str, budget: usize) -> Result<(), String> { + if buf.len() > budget { + return Err(format!("score too large: over {} bytes", budget)); + } + Ok(()) +} + /// Score Setup (File › Score Setup…). Stored in the session's edit flags/values. #[derive(Debug, Clone, PartialEq)] pub struct ScoreSetup { @@ -93,6 +110,9 @@ pub fn layout(s: &Session, tracks: &[TrackId]) -> Vec { let base = s.tempo.samples_to_ticks(c.start, sr); let end = s.tempo.samples_to_ticks(c.start.saturating_add(c.length), sr); for n in &sequence.notes { + if notes.len() >= MAX_NOTES_PER_PART { + break; + } let st = base.saturating_add(n.start); if st < base || st >= end || n.length <= 0 { continue; @@ -126,12 +146,20 @@ fn measure_grid(s: &Session, end_unit: i64) -> Vec<(i64, i64, u32, u32)> { let a = s.tempo.bar_start_tick(bar); let b = s.tempo.bar_start_tick(bar + 1); let m = s.tempo.meter_at_tick(a); - let (ua, ub) = (a / UNIT, b / UNIT); - if ub <= ua { + if b <= a { break; } - v.push((ua, ub - ua, m.numerator, m.denominator)); + let (ua, ub) = (a / UNIT, b / UNIT); bar += 1; + if ub <= ua { + // A bar shorter than a sixteenth that does not reach the next unit: the following + // bar carries this span, so the grid stays contiguous. + continue; + } + // Bars shorter than a sixteenth are written as one measure of the units they cover, so + // the time signature matches the measure's content. + let (num, den) = if m.ticks_per_bar() < UNIT { (u32::try_from(ub - ua).unwrap_or(1), 16) } else { (m.numerator, m.denominator) }; + v.push((ua, ub - ua, num, den)); if ub >= end_unit.max(1) || v.len() >= MAX_MEASURES { break; } @@ -151,13 +179,21 @@ fn split_units(mut units: i64) -> Vec { } fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec { - // Onsets → chords, each held until its longest note ends or the next onset. + // Onsets → chords, each held until its longest *retained* note ends or the next onset. A pitch + // the cap rejects must not extend the onset end. let mut onsets: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); for (a, b, p) in notes { - let e = onsets.entry(*a).or_insert((*b, Vec::new())); - e.0 = e.0.max(*b); - if !e.1.contains(p) { - e.1.push(*p); + let (ends, pitches) = onsets.entry(*a).or_insert((*b, Vec::new())); + let new_pitch = !pitches.contains(p); + if new_pitch && pitches.len() >= MAX_PITCHES_PER_CHORD { + // Chord full and this pitch is absent: it is dropped and must not extend the onset end. + continue; + } + // A retained pitch (already in the chord, or one the cap accepts) may extend the onset + // end, even when it reappears with a longer duration. + *ends = (*ends).max(*b); + if new_pitch { + pitches.push(*p); } } let starts: Vec = onsets.keys().copied().collect(); @@ -196,10 +232,33 @@ fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec } emit(from, to, ps, *a < *ms, *b > me, &mut items); cursor = to; + // Cap items per measure to avoid pathological memory use. + if items.len() > MAX_ITEMS_PER_MEASURE { + break; + } } if cursor < me { emit(cursor, me, &[], false, false, &mut items); } + // Cap the item count without shortening the measure: whatever suffix the cap discards is + // replaced with representable rests, so the bar's duration (and the SVG layout denominator) + // stays right. Shrinking the item list further makes room for those rests. + if items.len() > MAX_ITEMS_PER_MEASURE { + let bar_len = me.saturating_sub(*ms); + let mut keep = MAX_ITEMS_PER_MEASURE; + loop { + let kept: i64 = items.iter().take(keep).map(|i| i64::from(i.units)).sum(); + let rests = split_units(bar_len.saturating_sub(kept)); + if keep.saturating_add(rests.len()) <= MAX_ITEMS_PER_MEASURE || keep == 0 { + items.truncate(keep); + items.extend(rests.into_iter().map(|units| Item { pitches: Vec::new(), units, tie_start: false, tie_stop: false })); + break; + } + keep -= 1; + } + // Safety net for a hostile meter so wide that even whole-bar rests exceed the cap. + items.truncate(MAX_ITEMS_PER_MEASURE); + } measures.push(Measure { numerator: *num, denominator: *den, items }); } measures @@ -214,7 +273,13 @@ fn spell(pitch: u8) -> (char, i32, i32) { } fn xml_escape(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + s.chars() + .filter(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')) + .collect::() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) } fn note_type(units: u32) -> (&'static str, bool) { @@ -231,11 +296,20 @@ fn note_type(units: u32) -> (&'static str, bool) { } /// MusicXML 4.0 (partwise) for `parts`. -pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String { +pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result { + musicxml_budgeted(parts, setup, MAX_OUTPUT_BYTES) +} + +/// [`musicxml`] with an explicit byte budget (kept separate so tests can pass a tiny budget +/// instead of building a 256 MiB score). +fn musicxml_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result { let mut x = String::new(); x.push_str("\n"); x.push_str("\n"); x.push_str("\n"); + // The header (and an oversized title/composer, escaped) can itself exceed the budget even with + // no measures, so check before the per-measure loop too. + over_budget(&x, budget)?; let _ = writeln!(x, " {}", xml_escape(&setup.title)); x.push_str(" \n"); if !setup.composer.is_empty() { @@ -246,6 +320,7 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String { let _ = writeln!(x, " {}", i + 1, xml_escape(&p.name)); } x.push_str(" \n"); + over_budget(&x, budget)?; for (i, p) in parts.iter().enumerate() { let _ = writeln!(x, " ", i + 1); let mut meter = (0, 0); @@ -312,11 +387,13 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String { } } x.push_str(" \n"); + over_budget(&x, budget)?; } x.push_str(" \n"); } x.push_str("\n"); - x + over_budget(&x, budget)?; + Ok(x) } /// Staff position of `pitch` in half-spaces above the bottom line (treble E4 / bass G2). @@ -328,7 +405,13 @@ fn staff_step(pitch: u8, bass: bool) -> i32 { } /// An engraved score as a printable SVG page (A4 portrait width, as tall as needed). -pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String { +pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result { + svg_budgeted(parts, setup, MAX_OUTPUT_BYTES) +} + +/// [`svg`] with an explicit byte budget (kept separate so tests can pass a tiny budget instead of +/// building a 256 MiB score). +fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result { const W: f64 = 794.0; const MARGIN: f64 = 48.0; const SP: f64 = 7.0; // staff space @@ -350,7 +433,9 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String { let _ = writeln!(o, "{}", W / 2.0, xml_escape(&setup.title)); if !setup.composer.is_empty() { let _ = writeln!(o, "{}", W - MARGIN, xml_escape(&setup.composer)); + over_budget(&o, budget)?; } + over_budget(&o, budget)?; let bar_w = (staff_x1 - staff_x0) / per as f64; for sys in 0..systems { let y_sys = top + sys as f64 * sys_h; @@ -462,7 +547,13 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String { let _ = writeln!(o, "", x + 3.0); } } + // Check each measure, so a run of rest-only or missing measures that only draws + // bar lines cannot grow the output past the budget untouched. + over_budget(&o, budget)?; } + // The staff markup (lines, clef, track names) itself grows with the part count even + // without any items, so check once per staff too. + over_budget(&o, budget)?; } // System bracket. let y_last = y_sys + (parts.len().max(1) - 1) as f64 * STAFF_GAP + 4.0 * SP; @@ -472,9 +563,11 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String { staff_x0 - 30.0, staff_x0 - 30.0 ); + over_budget(&o, budget)?; } o.push_str("\n"); - o + over_budget(&o, budget)?; + Ok(o) } fn rest(o: &mut String, x: f64, y0: f64, sp: f64, units: u32) { @@ -552,10 +645,167 @@ mod tests { let parts = layout(e.session(), &[]); assert!(!parts.is_empty(), "demo has MIDI tracks"); let setup = ScoreSetup::from_session(e.session()); - let x = musicxml(&parts, &setup); + let x = musicxml(&parts, &setup).expect("demo musicxml fits"); assert!(x.contains("") && x.trim_end().ends_with("")); assert_eq!(x.matches("").count()); - let s = svg(&parts, &setup); + let s = svg(&parts, &setup).expect("demo svg fits"); assert!(s.starts_with(" usize { + parts.iter().flat_map(|p| &p.measures).flat_map(|m| &m.items).filter(|i| !i.tie_stop).map(|i| i.pitches.len()).sum() + } + + #[test] + fn bars_shorter_than_a_sixteenth_keep_every_note() { + let base = crate::demo::demo_engine(); + let baseline = note_starts(&layout(base.session(), &[])); + assert!(baseline > 0, "demo has notes"); + for (n, d) in [(1, 32), (1, 64), (2, 64), (3, 64)] { + for mid in [false, true] { + let mut e = crate::demo::demo_engine(); + let tick = if mid { e.session().tempo.bar_start_tick(5) } else { 0 }; + e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter"); + let parts = layout(e.session(), &[]); + assert!(parts.iter().all(|p| !p.measures.is_empty()), "{n}/{d} mid={mid}: empty part"); + assert_eq!(note_starts(&parts), baseline, "{n}/{d} mid={mid}: notes lost"); + for p in &parts { + for m in &p.measures { + let units: u32 = m.items.iter().map(|i| i.units).sum(); + // The written meter matches the measure's content (divisions are sixteenths). + assert_eq!(units * m.denominator, 16 * m.numerator, "{n}/{d} mid={mid}: meter/content mismatch"); + } + } + let setup = ScoreSetup::from_session(e.session()); + let x = musicxml(&parts, &setup).expect("musicxml fits"); + assert!(x.contains(""), "{n}/{d} mid={mid}: musicxml has no notes"); + let s = svg(&parts, &setup).expect("svg fits"); + assert!(s.contains(" = (0..100).map(|i| (i * 16, 16, 4, 4)).collect(); + let mut notes = Vec::new(); + for i in 0..2000 { + notes.push((i * 2, i * 2 + 1, 60 + (i % 24) as u8)); + } + let measures = fill(&bars, ¬es); + let part = Part { name: "dense".into(), bass: false, measures }; + let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 4, show_track_names: false }; + let s = svg(&[part], &setup); + assert!(s.is_ok(), "dense but bounded score fits"); + let s = s.unwrap(); + assert!(s.len() <= MAX_OUTPUT_BYTES); + } + + #[test] + fn oversize_score_is_refused_not_aborted() { + // Many dense parts drive the output past the byte budget: the call must return an error, + // not grow a multi-GB string or abort. Use the budgeted entry points with a tiny budget so + // the test exercises the over-budget path without allocating 256 MiB of score, and covers + // both the per-measure checks and the header/staff-only growth. + let bars: Vec<_> = (0..100).map(|i| (i * 16, 16, 4, 4)).collect(); + let mut notes = Vec::new(); + for i in 0..2000 { + let onset = i * 2; + notes.push((onset, onset + 1, 60 + (i % 24) as u8)); + } + let measures = fill(&bars, ¬es); + let part = Part { name: "dense".into(), bass: false, measures }; + let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 1, show_track_names: true }; + // Enough copies of the dense part to exceed a tiny budget. + let parts: Vec = (0..64).map(|i| Part { name: format!("part{i}"), ..part.clone() }).collect(); + let tiny = 1024; + let err = svg_budgeted(&parts, &setup, tiny).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + + // MusicXML needs a budget that clears the header/part-list but trips in the measures, so + // the per-measure check is what refuses the document (this test used to fail on the + // part-list check alone). + let boundless = usize::MAX; + // The header plus part-list exactly as `parse` writes it, but with no measures: that is + // the size a budget must clear before the per-measure check is reached. + let header_parts: Vec = parts.iter().map(|p| Part { name: p.name.clone(), bass: p.bass, measures: Vec::new() }).collect(); + let header = musicxml_budgeted(&header_parts, &setup, boundless).map_or(0, |x| x.len()); + let full = musicxml_budgeted(&parts, &setup, boundless).map_or(0, |x| x.len()); + assert!(header > 0, "the bare header fits any budget"); + assert!(full > header, "the measures add output ({full} vs {header})"); + // A budget above the bare header but below the completed document must be refused by the + // per-measure check. + let between = header.saturating_add((full.saturating_sub(header)) / 2); + assert!(between > header && between < full, "budget sits between header and document"); + // The header alone fits that budget, so the refusal below comes from the measures. + assert!(musicxml_budgeted(&header_parts, &setup, between).is_ok(), "header fits the mid budget"); + let err = musicxml_budgeted(&parts, &setup, between).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + // And a header that does not fit is refused before any measure is built. + let err = musicxml_budgeted(&parts, &setup, tiny).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + } + + #[test] + fn item_cap_keeps_the_measure_duration() { + // A 64/1 bar (256 sixteenths) with one note per sixteenth needs 256 items: over the cap. + // What the cap discards must come back as rests, so the bar still fills completely and the + // SVG layout denominator stays right. + let bars = vec![(0i64, 256i64, 64u32, 4u32)]; + let notes: Vec<(i64, i64, u8)> = (0..256).map(|i| (i, i + 1, 60)).collect(); + let measures = fill(&bars, ¬es); + assert_eq!(measures.len(), 1); + let m = &measures[0]; + assert!(m.items.len() <= MAX_ITEMS_PER_MEASURE, "capped at {MAX_ITEMS_PER_MEASURE}"); + let total: u32 = m.items.iter().map(|i| i.units).sum(); + assert_eq!(total, 256, "the bar still lasts 256 sixteenths"); + assert!(m.items.iter().any(|i| i.pitches.is_empty()), "the discarded suffix became rests"); + } + + #[test] + fn oversized_title_is_refused_by_the_header_check() { + // An oversized part name/title must be caught by the header check even with no measures: + // the escaped copy alone exceeds the budget. (A 54 MiB title blows past 64 KiB of escaped + // text without touching the per-measure loop.) + let setup = ScoreSetup { title: "&".repeat(64 * 1024), composer: "c".repeat(64 * 1024), bars_per_system: 4, show_track_names: false }; + let err = musicxml_budgeted(&[], &setup, 1024).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + let err = svg_budgeted(&[], &setup, 1024).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + } + + #[test] + fn exports_drop_characters_xml_forbids() { + let e = crate::demo::demo_engine(); + let mut parts = layout(e.session(), &[]); + parts[0].name = "Lead\u{1}1\u{FFFE}".to_string(); + let mut setup = ScoreSetup::from_session(e.session()); + setup.title = "T\u{2}itle".to_string(); + setup.composer = "C\u{3}omp".to_string(); + for out in [musicxml(&parts, &setup).expect("musicxml fits"), svg(&parts, &setup).expect("svg fits")] { + let bad: Vec = + out.chars().filter(|c| (*c < ' ' && !matches!(c, '\n' | '\t' | '\r')) || matches!(c, '\u{FFFE}' | '\u{FFFF}')).collect(); + assert!(bad.is_empty(), "forbidden characters: {bad:?}"); + } + } } diff --git a/crates/engine/tests/it/edit_modes.rs b/crates/engine/tests/it/edit_modes.rs index 27d4a649..17258d76 100644 --- a/crates/engine/tests/it/edit_modes.rs +++ b/crates/engine/tests/it/edit_modes.rs @@ -299,6 +299,22 @@ fn shuffle_clear_moves_automation_with_the_clips() { assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]); } +/// Clearing selected clips by id closes their gaps in Shuffle, as clearing their ranges does, and moves the +/// automation with the clips. +#[test] +fn shuffle_clear_by_clip_id_closes_each_gap() { + for (mode, want, want_points) in [ + ("shuffle", [(0, 10_000), (20_000, 10_000)], [0, 9_000, 20_000, 29_000]), + ("slip", [(0, 10_000), (40_000, 10_000)], [0, 9_000, 40_000, 49_000]), + ] { + let (mut e, t, ids) = long(mode, &[(0, 10_000, 0), (20_000, 10_000, 0), (30_000, 10_000, 0), (40_000, 10_000, 0)]); + set_points(&mut e, t, &[0, 9_000, 40_000, 49_000]); + run(&mut e, "edit.clear", json!({"clips": [ids[1].0, ids[2].0], "tracks": [t.0], "start": 0})); + assert_eq!(clips(&e, t), want, "{mode}"); + assert_eq!(points(&e, t), want_points, "{mode}"); + } +} + #[test] fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() { let (mut e, t, [a, _]) = engine("relative_grid"); @@ -413,3 +429,30 @@ fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() { run(&mut e, "edit.mode", json!({"mode": "grid"})); assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err()); } + +fn selection_marker(e: &mut Engine, start: i64, end: i64) -> usize { + run(e, "markers.add", json!({"kind": "selection", "start": start, "end": end})); + e.session().markers.len() - 1 +} + +#[test] +fn cut_and_insert_time_move_the_end_of_a_spanning_selection_memory_location() { + let (mut e, _, _) = engine("slip"); + let a = selection_marker(&mut e, 1000, 5000); + let b = selection_marker(&mut e, 1000, 2500); + run(&mut e, "event.cut_time", json!({"start": 2000, "end": 3000})); + let m = &e.session().markers; + assert_eq!((m[a].start, m[a].end), (1000, 4000)); + assert_eq!((m[b].start, m[b].end), (1000, 2000)); + run(&mut e, "event.insert_time", json!({"start": 2000, "length": 1000})); + let m = &e.session().markers; + assert_eq!((m[a].start, m[a].end), (1000, 5000)); + assert_eq!((m[b].start, m[b].end), (1000, 2000)); +} + +#[test] +fn moving_a_group_left_past_zero_keeps_its_spacing() { + let (mut e, t, ids) = long("slip", &[(500, 100, 0), (2000, 100, 0)]); + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": -1000})); + assert_eq!(clips(&e, t), [(0, 100), (1500, 100)]); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 611c7266..1dd0b391 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -11,10 +11,13 @@ mod hostile_params; mod hostile_sessions; mod long_renders; mod midi_offset; +mod min_secs_with_timecode_counter; mod mix_required_params; +mod new_track_rows; mod random_edits; mod recording; mod session_transport; mod split_into_mono; +mod time_key_signatures; mod track_name_collisions; mod trim_to_selection; diff --git a/crates/engine/tests/it/midi_offset.rs b/crates/engine/tests/it/midi_offset.rs index e4a1b1ba..9953d08d 100644 --- a/crates/engine/tests/it/midi_offset.rs +++ b/crates/engine/tests/it/midi_offset.rs @@ -28,3 +28,71 @@ fn midi_track_offset_moves_the_notes_like_moving_the_clip() { e.execute("edit.move_clips", &json!({"clip": clip, "by": 24_000})).unwrap(); assert!(render(&e) == offset, "the offset must match moving the clip by 24000 samples"); } + +#[test] +fn export_midi_leaves_out_what_a_trimmed_clip_no_longer_plays() { + let mut e = Engine::default(); + let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Trim", "instrument": "subtractive_synth"})).unwrap(); + let track = r["tracks"][0].as_u64().unwrap(); + let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap(); + let clip = r["clip"].as_u64().unwrap(); + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0, "length_ticks": 960, "velocity": 100})).unwrap(); + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 62, "start_ticks": 800, "length_ticks": 960, "velocity": 100})).unwrap(); + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 64, "start_ticks": 2000, "length_ticks": 960, "velocity": 100})).unwrap(); + // 24000 samples is 960 ticks at 120 bpm. + let id = e.session().tracks[0].clips()[0].id; + e.session_mut().find_clip_mut(id).unwrap().trim_end_to(24_000); + + let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap(); + let smf = soundcraft_midi::read_smf(&bytes).unwrap(); + let notes = &smf.tracks[0].sequence.notes; + assert_eq!(notes.len(), 2, "only notes starting inside the clip are exported"); + let late = notes.iter().find(|n| n.pitch == 62).unwrap(); + assert_eq!(late.start + late.length, 960, "a note running past the clip end is cut at it"); +} + +#[test] +fn export_midi_releases_a_sustain_held_past_the_trim() { + use soundcraft_midi::{CtrlEvent, CtrlKind}; + let mut e = Engine::default(); + let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Sus", "instrument": "subtractive_synth"})).unwrap(); + let track = r["tracks"][0].as_u64().unwrap(); + e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap(); + let id = e.session().tracks[0].clips()[0].id; + { + let clip = e.session_mut().find_clip_mut(id).unwrap(); + if let soundcraft_model::ClipContent::Midi { sequence } = &mut clip.content { + let cc = |tick, channel, value| CtrlEvent { tick, channel, kind: CtrlKind::Cc(64), value }; + sequence.ctrls = vec![cc(100, 0, 127), cc(200, 1, 127), cc(300, 1, 0), cc(2000, 0, 0)]; + } + clip.trim_end_to(24_000); + } + let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap(); + let smf = soundcraft_midi::read_smf(&bytes).unwrap(); + let ctrls = &smf.tracks[0].sequence.ctrls; + let sus: Vec<_> = ctrls.iter().filter(|c| c.kind == CtrlKind::Cc(64)).collect(); + assert!(sus.iter().any(|c| c.channel == 0 && c.tick == 960 && c.value == 0), "{sus:?}"); + assert!(!sus.iter().any(|c| c.channel == 1 && c.tick == 960), "released channel gets no extra event: {sus:?}"); +} + +#[test] +fn export_midi_keeps_the_notes_of_a_clip_trimmed_at_both_ends_in_place() { + let mut e = Engine::default(); + let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Both", "instrument": "subtractive_synth"})).unwrap(); + let track = r["tracks"][0].as_u64().unwrap(); + let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap(); + let clip = r["clip"].as_u64().unwrap(); + for (pitch, start) in [(60, 0), (62, 1000), (64, 2000)] { + e.execute("midi.note_add", &json!({"clip": clip, "pitch": pitch, "start_ticks": start, "length_ticks": 960, "velocity": 100})).unwrap(); + } + // 24000 samples is 960 ticks and 72000 samples is 2880 ticks at 120 bpm. + e.execute("edit.trim_start_to_insertion", &json!({"tracks": [track], "at": 24_000})).unwrap(); + e.execute("edit.trim_end_to_insertion", &json!({"tracks": [track], "at": 72_000})).unwrap(); + + let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap(); + let smf = soundcraft_midi::read_smf(&bytes).unwrap(); + let mut notes: Vec<_> = smf.tracks[0].sequence.notes.iter().map(|n| (n.pitch, n.start, n.length)).collect(); + notes.sort_unstable(); + // The note cut by the start trim is gone; the others keep their place and the last stops at the end. + assert_eq!(notes, vec![(62, 1000, 960), (64, 2000, 880)]); +} diff --git a/crates/engine/tests/it/min_secs_with_timecode_counter.rs b/crates/engine/tests/it/min_secs_with_timecode_counter.rs new file mode 100644 index 00000000..a66d2f6b --- /dev/null +++ b/crates/engine/tests/it/min_secs_with_timecode_counter.rs @@ -0,0 +1,11 @@ +use serde_json::json; +use soundcraft_engine::Engine; + +#[test] +fn min_secs_string_is_not_misread_as_timecode_when_the_counter_is_timecode() { + let mut e = Engine::default(); + e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap(); + e.execute("edit.select", &json!({"start": "0:12.500", "end": "0:20.000"})).unwrap(); + let sel = e.session().edit.selection; + assert_eq!((sel.start, sel.end), (600_000, 960_000)); +} diff --git a/crates/engine/tests/it/new_track_rows.rs b/crates/engine/tests/it/new_track_rows.rs new file mode 100644 index 00000000..1a80b8d6 --- /dev/null +++ b/crates/engine/tests/it/new_track_rows.rs @@ -0,0 +1,82 @@ +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_model::{ChannelFormat, TrackKind}; + +/// Mixed rows preserve their order and share one selection and history step. +#[test] +fn mixed_rows_create_in_order_select_every_track_and_undo_in_one_step() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"name": "Existing"})).unwrap(); + let before = e.session().to_json().unwrap(); + let result = e + .execute( + "track.new", + &json!({"rows": [ + {"count": 2, "kind": "audio", "format": "mono", "name": "Dialogue", "timebase": "samples"}, + {"kind": "aux", "format": "stereo", "name": "Reverb", "timebase": "samples"}, + {"kind": "instrument", "format": "stereo", "name": "Keys", "timebase": "ticks", "instrument": "subtractive_synth"}, + {"kind": "folder", "format": "stereo", "name": "Music"} + ]}), + ) + .unwrap(); + let s = e.session(); + let created = &s.tracks[1..]; + assert_eq!(created.len(), 5); + assert_eq!( + created.iter().map(|t| t.kind).collect::>(), + [TrackKind::Audio, TrackKind::Audio, TrackKind::Aux, TrackKind::Instrument, TrackKind::Folder] + ); + assert_eq!( + created.iter().map(|t| t.format).collect::>(), + [ChannelFormat::Mono, ChannelFormat::Mono, ChannelFormat::Stereo, ChannelFormat::Stereo, ChannelFormat::Stereo] + ); + assert!(!created[0].ticks_timebase); + assert!(created[3].ticks_timebase && created[3].instrument.is_some()); + assert_eq!(s.busses.len(), 1); + let ids = created.iter().map(|t| t.id).collect::>(); + assert_eq!(s.edit.selected_tracks, ids); + assert_eq!(result["tracks"], json!(ids)); + let after = s.to_json().unwrap(); + assert!(e.undo()); + assert_eq!(e.session().to_json().unwrap(), before); + assert!(e.redo()); + assert_eq!(e.session().to_json().unwrap(), after); +} + +/// A failure in a later row rolls the complete command back atomically. +#[test] +fn invalid_later_row_leaves_no_partial_creation_or_undo_step() { + for field in [json!({"kind": "unknown"}), json!({"format": "unknown"})] { + let mut e = Engine::default(); + let before = e.session().to_json().unwrap(); + assert!(e.execute("track.new", &json!({"rows": [{"kind": "folder"}, field]})).is_err()); + assert_eq!(e.session().to_json().unwrap(), before); + assert!(!e.can_undo()); + assert!(e.journal.is_empty()); + } +} + +/// Row structure and aggregate track count reject malformed or oversized requests. +#[test] +fn row_shape_and_total_size_are_bounded() { + for rows in [json!(null), json!({}), json!([]), json!([true]), json!([{"rows": []}]), json!([{"count": 128}, {}]), json!(vec![json!({}); 129])] { + let mut e = Engine::default(); + assert!(e.execute("track.new", &json!({"rows": rows})).is_err(), "{rows}"); + assert!(e.session().tracks.is_empty()); + assert!(!e.can_undo()); + } + let mut e = Engine::default(); + e.execute("track.new", &json!({"rows": [{"count": 64}, {"count": 64}]})).unwrap(); + assert_eq!(e.session().tracks.len(), 128); +} + +/// Calls without `rows` retain the original defaults and per-row count clamp. +#[test] +fn legacy_single_row_parameters_keep_their_defaults_and_count_clamp() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"kind": "aux", "count": -1})).unwrap(); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().tracks[0].format, ChannelFormat::Stereo); + e.execute("track.new", &json!({"count": 200})).unwrap(); + assert_eq!(e.session().tracks.len(), 129); +} diff --git a/crates/engine/tests/it/time_key_signatures.rs b/crates/engine/tests/it/time_key_signatures.rs new file mode 100644 index 00000000..8b1d5860 --- /dev/null +++ b/crates/engine/tests/it/time_key_signatures.rs @@ -0,0 +1,41 @@ +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_model::{ChannelFormat, Session, TrackKind}; + +fn engine_with_keys() -> Engine { + let mut session = Session::default(); + session.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut engine = Engine::new(session); + for (at, key) in [(10, "C major"), (48000, "D major"), (100000, "E major")] { + engine.execute("event.add_key_change", &json!({"key": key, "at": at})).unwrap(); + } + engine +} + +fn keys(engine: &Engine) -> Vec<(u64, String)> { + engine.session().key_signatures.iter().map(|(at, k)| (*at as u64, k.clone())).collect() +} + +fn k(at: u64, key: &str) -> (u64, String) { + (at, key.to_string()) +} + +#[test] +fn insert_time_moves_key_signatures() { + let mut engine = engine_with_keys(); + let original = keys(&engine); + engine.execute("event.insert_time", &json!({"start": 48000, "length": 1000})).unwrap(); + assert_eq!(keys(&engine), vec![k(10, "C major"), k(49000, "D major"), k(101000, "E major")]); + engine.undo(); + assert_eq!(keys(&engine), original); +} + +#[test] +fn cut_time_removes_and_moves_key_signatures() { + let mut engine = engine_with_keys(); + let original = keys(&engine); + engine.execute("event.cut_time", &json!({"start": 20, "end": 60000})).unwrap(); + assert_eq!(keys(&engine), vec![k(10, "C major"), k(40020, "E major")]); + engine.undo(); + assert_eq!(keys(&engine), original); +} diff --git a/crates/midi/src/ops.rs b/crates/midi/src/ops.rs index 16324462..e6da9959 100644 --- a/crates/midi/src/ops.rs +++ b/crates/midi/src/ops.rs @@ -178,9 +178,10 @@ pub fn change_velocity_with_seed(notes: &mut [Note], op: VelocityOp, seed: u64) VelocityOp::Ramp { from, to } => { let first = notes.iter().map(|n| n.start).min().unwrap_or(0); let last = notes.iter().map(|n| n.start).max().unwrap_or(0); - let span = last.saturating_sub(first) as f64; + let span = (i128::from(last) - i128::from(first)) as f64; for n in notes.iter_mut() { - let t = if span > 0.0 { n.start.saturating_sub(first) as f64 / span } else { 0.0 }; + let offset = (i128::from(n.start) - i128::from(first)) as f64; + let t = if span > 0.0 { offset / span } else { 0.0 }; let v = f64::from(from) + (f64::from(to) - f64::from(from)) * t; n.velocity = clamp_vel(round_i64(v)); } diff --git a/crates/midi/tests/it/ops.rs b/crates/midi/tests/it/ops.rs index 1aed370f..3a367ee0 100644 --- a/crates/midi/tests/it/ops.rs +++ b/crates/midi/tests/it/ops.rs @@ -106,6 +106,13 @@ fn transpose_clamps() { assert!(notes.iter().all(|n| n.pitch == 127)); } +#[test] +fn ramp_full_tick_range() { + let mut notes = vec![n(60, i64::MIN, 1), n(60, 0, 1), n(60, i64::MAX, 1)]; + change_velocity(&mut notes, VelocityOp::Ramp { from: 1, to: 127 }); + assert_eq!(notes.iter().map(|n| n.velocity).collect::>(), vec![1, 64, 127]); +} + #[test] fn velocity_ops() { let base = vec![n(60, 0, 1), n(60, 100, 1), n(60, 200, 1)]; diff --git a/crates/mix/src/automation.rs b/crates/mix/src/automation.rs index 20b20225..5bdf7dd9 100644 --- a/crates/mix/src/automation.rs +++ b/crates/mix/src/automation.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AutoParam, Samples, Session, TICKS_PER_QUARTER, Track, Transport, db_to_gain}; pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport { let sr = s.sample_rate; @@ -65,7 +65,7 @@ pub(crate) fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 { && let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0))) && !l.points.is_empty() { - return l.value_at(pos, base); + return l.value_at(pos, base).clamp(soundcraft_dsp::MIN_DB, crate::SEND_LEVEL_MAX_DB); } base } diff --git a/crates/mix/src/clips.rs b/crates/mix/src/clips.rs index 03449de8..c70f620d 100644 --- a/crates/mix/src/clips.rs +++ b/crates/mix/src/clips.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Bound, Clip, ClipContent, Plugin, Range, Samples, Session, db_to_gain}; /// Clip Effects: an EQ and a compressor per clip, configured from `edit.values` /// (`clip_fx..eq.`, `clip_fx..comp.`, `clip_fx..gain`). @@ -101,7 +101,7 @@ impl ClipFx { c.set_param(p, v); } } else if k == "gain" { - self.gain_db = v; + self.gain_db = if v.is_nan() { 0.0 } else { v.clamp(soundcraft_dsp::MIN_DB, crate::CLIP_FX_MAX_GAIN_DB) }; } } } diff --git a/crates/mix/src/instances.rs b/crates/mix/src/instances.rs index 5050007e..8bb5dbf9 100644 --- a/crates/mix/src/instances.rs +++ b/crates/mix/src/instances.rs @@ -1,5 +1,5 @@ use super::strip::strip_channels; -use super::*; +use super::{Plugin, Session, TrackId, main_channels}; /// Slot index used for a track's instrument plugin in [`InstanceSpec`] and [`PreparedInstance`] /// (insert slots use their index). diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index b32202a0..a671d0b6 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -59,7 +59,6 @@ use soundcraft_model::{ use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER}; use std::collections::HashMap; use std::ops::Bound; -use std::sync::Arc; mod automation; mod clips; @@ -82,12 +81,19 @@ pub use offline::{ pub use pan::itu_stereo_fold; use pan::{PanSpec, apply_matrix, apply_matrix_span, build_matrix}; use routing::{processing_order, structure_fingerprint}; -use strip::{BusBuf, LiveInput, SlotCtx, Strip, apply_params, contain_failure, note_insert_latency, process_strip, strip_channels, strip_format}; +use strip::{ + Block, BusBuf, LiveInput, SlotCtx, Strip, apply_params, contain_failure, note_insert_latency, process_strip, strip_channels, strip_format, +}; #[cfg(test)] use strip::{DelayLine, MAX_LATENCY}; use threads::run_strips; pub use threads::{mark_audio_thread, on_audio_thread}; +/// Top of the clip effects gain slider; a stored or wired gain is clamped to it (#884). +pub const CLIP_FX_MAX_GAIN_DB: f32 = 24.0; +/// Top of a send fader, the same +12 dB `AutoParam::SendLevel` ranges up to (#884). +pub const SEND_LEVEL_MAX_DB: f32 = 12.0; + /// Default (stereo) main mix width. The actual width is [`main_channels`]. pub const MAIN_CHANNELS: usize = 2; @@ -349,6 +355,44 @@ impl MixEngine { /// Render `frames` (≤ max_block) samples starting at `pos` into `out` (≥ 2 channels). pub fn render(&mut self, s: &Session, pos: Samples, frames: usize, out: &mut [Vec]) { let frames = frames.min(self.max_block); + self.begin_block(s, pos, frames); + let blk = Block { pos, frames, any_solo: s.tracks.iter().any(|t| t.mixer.solo && !t.inactive) }; + let order = std::mem::take(&mut self.order); + // Phase 1: independent strips (audio, instrument, MIDI) in parallel. + let mut work = std::mem::take(&mut self.work); + work.clear(); + for &i in &order { + let Some(t) = s.tracks.get(i).filter(|t| is_live(t) && !is_aux(t)) else { continue }; + if let Some(st) = self.strips.remove(&t.id) { + work.push((i, st)); + } + } + for (_, st) in work.iter_mut() { + if st.scratch.len() < self.max_block { + st.scratch.resize(self.max_block, 0.0); + } + } + let busses = std::mem::take(&mut self.busses); + let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; + run_strips(&mut work, |(i, st)| { + if let Some(t) = s.tracks.get(*i) { + process_strip(s, t, st, &busses, &live_in, blk); + } + }); + self.busses = busses; + // Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait + // for the slowest aux return. Aux latency has to be read before those outputs are routed, + // or the first block (and a one-block bounce probe) still sees zero. + self.compensate_delay(s, &mut work, blk); + self.work = work; + self.render_auxes(s, &order, blk); + self.order = order; + self.render_masters(s, blk); + self.write_output(s, blk, out); + } + + /// Silence what a seek leaves ringing, sync to the session and clear the block's accumulators. + fn begin_block(&mut self, s: &Session, pos: Samples, frames: usize) { if pos != self.last_end { // Seek: silence held notes and ringing plugins. for st in self.strips.values_mut() { @@ -369,35 +413,11 @@ impl MixEngine { for c in &mut self.main { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } - let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive); - let order = std::mem::take(&mut self.order); - let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca); - let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder); - // Phase 1: independent strips (audio, instrument, MIDI) in parallel. - let mut work = std::mem::take(&mut self.work); - work.clear(); - for &i in &order { - let Some(t) = s.tracks.get(i).filter(|t| live(t) && !is_aux(t)) else { continue }; - if let Some(st) = self.strips.remove(&t.id) { - work.push((i, st)); - } - } - for (_, st) in work.iter_mut() { - if st.scratch.len() < self.max_block { - st.scratch.resize(self.max_block, 0.0); - } - } - let busses = std::mem::take(&mut self.busses); - let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; - run_strips(&mut work, |(i, st)| { - if let Some(t) = s.tracks.get(*i) { - process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo); - } - }); - self.busses = busses; - // Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait - // for the slowest aux return. Aux latency has to be read before those outputs are routed, - // or the first block (and a one-block bounce probe) still sees zero. + } + + /// Delay compensation for the independent strips (`work`), then route them back into the engine. + fn compensate_delay(&mut self, s: &Session, work: &mut Vec<(usize, Strip)>, blk: Block) { + let Block { pos, frames, .. } = blk; let pdc = s.edit.delay_compensation; if pdc { for t in s.tracks.iter().filter(|t| is_aux(t) && !t.inactive) { @@ -427,7 +447,7 @@ impl MixEngine { self.aux_latency = lmax_a; self.aux_fed.clear(); if pdc { - for t in s.tracks.iter().filter(|t| is_aux(t) && live(t)) { + for t in s.tracks.iter().filter(|t| is_aux(t) && is_live(t)) { if let Route::Bus(b) = &t.mixer.output && !self.aux_fed.contains(b) { @@ -461,16 +481,20 @@ impl MixEngine { self.strips.insert(t.id, st); } } - self.work = work; - let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder); - // Phase 2: auxes in dependency order (they read busses). - for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) { + } + + /// Phase 2: auxes in dependency order (they read busses). + fn render_auxes(&mut self, s: &Session, order: &[usize], blk: Block) { + let Block { pos, frames, .. } = blk; + let pdc = s.edit.delay_compensation; + let lmax_a = self.aux_latency; + for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| is_live(t)).filter(|t| is_aux(t)) { let Some(mut st) = self.strips.remove(&t.id) else { continue }; if st.scratch.len() < self.max_block { st.scratch.resize(self.max_block, 0.0); } let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; - process_strip(s, t, &mut st, &self.busses, &live_in, pos, frames, any_solo); + process_strip(s, t, &mut st, &self.busses, &live_in, blk); let ch = st.buf.len(); st.align.set(if pdc { lmax_a.saturating_sub(st.latency) } else { 0 }, ch); st.align.process(&mut st.buf, frames); @@ -478,7 +502,11 @@ impl MixEngine { self.route_strip(t, &mut st, pos, frames); self.strips.insert(t.id, st); } - self.order = order; + } + + /// Master faders process the main mix. + fn render_masters(&mut self, s: &Session, blk: Block) { + let Block { pos, frames, .. } = blk; // Master faders process the main mix. let transport = transport_at(s, pos, !self.monitor_only, self.recording); for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) { @@ -522,6 +550,11 @@ impl MixEngine { m.plugin_failures = strip.failures; } } + } + + /// Add the metronome and copy the main mix to `out`. + fn write_output(&mut self, s: &Session, blk: Block, out: &mut [Vec]) { + let Block { pos, frames, .. } = blk; // Metronome: summed after the master fader, so it reaches the speakers without passing // through any track, send or insert. Only the playback engine sets `metronome`. // Not while monitoring only (transport stopped): the position does not advance there, so the @@ -629,6 +662,14 @@ std::thread_local! { static AUDIO_THREAD: std::cell::Cell = const { std::cell::Cell::new(false) }; } +fn is_live(t: &Track) -> bool { + !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca) +} + +fn is_aux(t: &Track) -> bool { + matches!(t.kind, TrackKind::Aux | TrackKind::Folder) +} + /// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of /// each bar. Settings come from `setup.click_countoff` (`click.*` values). mod click; diff --git a/crates/mix/src/midi.rs b/crates/mix/src/midi.rs index 2e49790c..3b182956 100644 --- a/crates/mix/src/midi.rs +++ b/crates/mix/src/midi.rs @@ -1,6 +1,7 @@ use super::clips::Key; use super::strip::{PluginSlot, Strip}; -use super::*; +use super::{ClipContent, CtrlKind, Range, SampleSource, Samples, Session, SourceAudio, Track}; +use std::sync::Arc; /// The track's MIDI Track Offset (`midi.offset.` in the edit values), in samples. Looked up /// without formatting the key, since this runs on the audio thread every block. diff --git a/crates/mix/src/offline.rs b/crates/mix/src/offline.rs index ad70b918..d544b0fc 100644 --- a/crates/mix/src/offline.rs +++ b/crates/mix/src/offline.rs @@ -1,6 +1,6 @@ use super::clips::{ClipFx, clip_fx_params, render_clip}; use super::pan::itu_stereo_fold; -use super::*; +use super::{AutoParam, BusId, ChannelFormat, MixEngine, Range, Route, Samples, Session, TrackId, main_channels}; /// Ceiling on one in-memory offline render (samples summed over channels): 2^29 f32 = 2 GiB, about /// 93 minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory; diff --git a/crates/mix/src/pan.rs b/crates/mix/src/pan.rs index 6221ae4f..cb8047a7 100644 --- a/crates/mix/src/pan.rs +++ b/crates/mix/src/pan.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ChannelFormat, MAX_CHANNELS, PanLaw, Speaker, SpeakerPos, SurroundPan, SurroundParams, db_to_gain, gains}; /// Per-block routing gains: `m[src][dst]`. type Matrix = [[f32; MAX_CHANNELS]; MAX_CHANNELS]; @@ -48,6 +48,22 @@ fn surround_params(sp: &SurroundPan, dx: f32) -> SurroundParams { SurroundParams { x: (sp.x + dx).clamp(-1.0, 1.0), y: sp.y, z: sp.z, divergence: sp.divergence, center: sp.center / 100.0 } } +/// One source → destination routing: channel counts and formats, plus how the source is panned. +struct Link<'a> { + src_fmt: ChannelFormat, + ns: usize, + dst_fmt: ChannelFormat, + nd: usize, + pan: &'a PanSpec<'a>, + law: PanLaw, +} + +fn add_gain(m: &mut Matrix, s: usize, d: usize, g: f32) { + if let Some(x) = m.get_mut(s).and_then(|r| r.get_mut(d)) { + *x += g; + } +} + /// Routing gains from a source (`ns` channels of `src_fmt`) into a destination (`nd` channels of /// `dst_fmt`). See the crate docs for the rules. pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFormat, nd: usize, pan: &PanSpec<'_>, law: PanLaw) -> Matrix { @@ -56,11 +72,6 @@ pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFo if ns == 0 || nd == 0 { return m; } - let set = |m: &mut Matrix, s: usize, d: usize, g: f32| { - if let Some(x) = m.get_mut(s).and_then(|r| r.get_mut(d)) { - *x += g; - } - }; if nd == 1 { // Mono destination: the stereo routing summed at -6 dB. let st = build_matrix(src_fmt, ns, ChannelFormat::Stereo, 2, pan, law); @@ -69,131 +80,168 @@ pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFo } return m; } - let src_sp = src_fmt.speakers(); + let link = Link { src_fmt, ns, dst_fmt, nd, pan, law }; let src_amb = src_fmt.is_ambisonic() && ns >= 4; if nd == 2 && !dst_fmt.is_ambisonic() { - match ns { - 1 => { - let (gl, gr) = gains(pan.stereo[0], law); - m[0][0] = gl; - m[0][1] = gr; - } - 2 if !src_amb => { - let (a_l, a_r) = gains(pan.stereo[0], PanLaw::Zero); - let (b_l, b_r) = gains(pan.stereo[1], PanLaw::Zero); - m[0] = [0.0; MAX_CHANNELS]; - m[0][0] = a_l; - m[0][1] = a_r; - m[1][0] = b_l; - m[1][1] = b_r; - } - _ if src_amb => { - for (d, az) in [(0usize, -30.0f32), (1, 30.0)] { - let g = soundcraft_dsp::pan::ambisonic_decode(az, 0.0, 2); - for (c, gc) in g.iter().enumerate() { - set(&mut m, c, d, *gc); - } - } - } - _ if src_sp.len() == ns => { - for (s, sp) in src_sp.iter().enumerate() { - let (l, r) = itu_stereo_fold(*sp); - set(&mut m, s, 0, l); - set(&mut m, s, 1, r); - } - } - _ => { - // Unknown layout: first two channels to L/R, the rest to the centre. - let h = std::f32::consts::FRAC_1_SQRT_2; - m[0][0] = 1.0; - m[1][1] = 1.0; - for row in m.iter_mut().take(ns).skip(2) { - row[0] = h; - row[1] = h; - } - } - } - return m; + return stereo_dest(&link, src_amb); } if ns > 2 && src_fmt == dst_fmt && ns == nd { return identity_matrix(ns); } - // Multichannel destination. - let mut layout = [SpeakerPos::default(); MAX_CHANNELS]; - let nl = layout_of(dst_fmt, &mut layout); - let layout = layout.get(..nl).unwrap_or(&[]); - let dst_sp = dst_fmt.speakers(); - let dst_amb = dst_fmt.is_ambisonic(); - let find = |sp: Speaker| dst_sp.iter().position(|x| *x == sp).filter(|i| *i < nd); - let mut g = [0.0f32; MAX_CHANNELS]; - if dst_amb { - if src_amb || (src_fmt.is_ambisonic() && ns > 2) { - return identity_matrix(ns.min(nd)); - } - let mut enc = |m: &mut Matrix, s: usize, az: f32, el: f32, spread: f32| { - soundcraft_dsp::pan::ambisonic_encode(az, el, spread, &mut g); - for (d, gd) in g.iter().enumerate().take(nd) { - set(m, s, d, *gd); - } - }; - match (ns, pan.surround) { - (1 | 2, Some(sp)) => { - for s in 0..ns { - let dx = if ns == 2 { if s == 0 { -1.0 } else { 1.0 } } else { 0.0 }; - let p = surround_params(sp, dx); - enc(&mut m, s, soundcraft_dsp::pan::puck_azimuth(&[], p.x, p.y), p.z * 90.0, p.divergence); - } - } - (1, None) => enc(&mut m, 0, pan.stereo[0].clamp(-1.0, 1.0) * 30.0, 0.0, 0.0), - (2, None) => { - enc(&mut m, 0, -30.0, 0.0, 0.0); - enc(&mut m, 1, 30.0, 0.0, 0.0); - } - _ => { - for (s, sp) in src_sp.iter().enumerate().take(ns) { - if !sp.is_lfe() { - enc(&mut m, s, sp.azimuth(), sp.elevation(), 0.0); - } - } - } - } - return m; + if dst_fmt.is_ambisonic() { + return ambisonic_dest(&link, src_amb); } + layout_dest(&link, src_amb) +} + +/// Routing into a stereo destination. +fn stereo_dest(l: &Link<'_>, src_amb: bool) -> Matrix { + let mut m = [[0.0f32; MAX_CHANNELS]; MAX_CHANNELS]; + let src_sp = l.src_fmt.speakers(); + match l.ns { + 1 => { + let (gl, gr) = gains(l.pan.stereo[0], l.law); + m[0][0] = gl; + m[0][1] = gr; + } + 2 if !src_amb => { + let (a_l, a_r) = gains(l.pan.stereo[0], PanLaw::Zero); + let (b_l, b_r) = gains(l.pan.stereo[1], PanLaw::Zero); + m[0] = [0.0; MAX_CHANNELS]; + m[0][0] = a_l; + m[0][1] = a_r; + m[1][0] = b_l; + m[1][1] = b_r; + } + _ if src_amb => { + for (d, az) in [(0usize, -30.0f32), (1, 30.0)] { + let g = soundcraft_dsp::pan::ambisonic_decode(az, 0.0, 2); + for (c, gc) in g.iter().enumerate() { + add_gain(&mut m, c, d, *gc); + } + } + } + ns if src_sp.len() == ns => { + for (s, sp) in src_sp.iter().enumerate() { + let (left, right) = itu_stereo_fold(*sp); + add_gain(&mut m, s, 0, left); + add_gain(&mut m, s, 1, right); + } + } + ns => { + // Unknown layout: first two channels to L/R, the rest to the centre. + let h = std::f32::consts::FRAC_1_SQRT_2; + m[0][0] = 1.0; + m[1][1] = 1.0; + for row in m.iter_mut().take(ns).skip(2) { + row[0] = h; + row[1] = h; + } + } + } + m +} + +/// `±1` offset spreading a stereo source's two channels left and right of the surround pan. +fn spread_dx(ns: usize, s: usize) -> f32 { + if ns != 2 { + 0.0 + } else if s == 0 { + -1.0 + } else { + 1.0 + } +} + +/// Routing into an ambisonic destination. +fn ambisonic_dest(l: &Link<'_>, src_amb: bool) -> Matrix { + let mut m = [[0.0f32; MAX_CHANNELS]; MAX_CHANNELS]; + let (ns, nd) = (l.ns, l.nd); + if src_amb || (l.src_fmt.is_ambisonic() && ns > 2) { + return identity_matrix(ns.min(nd)); + } + let src_sp = l.src_fmt.speakers(); + let mut g = [0.0f32; MAX_CHANNELS]; + let mut enc = |m: &mut Matrix, s: usize, az: f32, el: f32, spread: f32| { + soundcraft_dsp::pan::ambisonic_encode(az, el, spread, &mut g); + for (d, gd) in g.iter().enumerate().take(nd) { + add_gain(m, s, d, *gd); + } + }; + match (ns, l.pan.surround) { + (1 | 2, Some(sp)) => { + for s in 0..ns { + let p = surround_params(sp, spread_dx(ns, s)); + enc(&mut m, s, soundcraft_dsp::pan::puck_azimuth(&[], p.x, p.y), p.z * 90.0, p.divergence); + } + } + (1, None) => enc(&mut m, 0, l.pan.stereo[0].clamp(-1.0, 1.0) * 30.0, 0.0, 0.0), + (2, None) => { + enc(&mut m, 0, -30.0, 0.0, 0.0); + enc(&mut m, 1, 30.0, 0.0, 0.0); + } + _ => { + for (s, sp) in src_sp.iter().enumerate().take(ns) { + if !sp.is_lfe() { + enc(&mut m, s, sp.azimuth(), sp.elevation(), 0.0); + } + } + } + } + m +} + +/// A surround-panned mono or stereo source into a speaker layout, plus its LFE send. +fn surround_into_layout(m: &mut Matrix, l: &Link<'_>, layout: &[SpeakerPos], sp: &SurroundPan) { + let (ns, nd) = (l.ns, l.nd); + let dst_sp = l.dst_fmt.speakers(); + let mut g = [0.0f32; MAX_CHANNELS]; + for s in 0..ns { + soundcraft_dsp::pan::surround_gains(layout, &surround_params(sp, spread_dx(ns, s)), &mut g); + for (d, gd) in g.iter().enumerate().take(nd) { + add_gain(m, s, d, *gd); + } + if let Some(lfe) = dst_sp.iter().position(|x| *x == Speaker::Lfe).filter(|i| *i < nd) + && sp.lfe_db > -143.9 + { + add_gain(m, s, lfe, db_to_gain(sp.lfe_db.min(12.0)) / ns as f32); + } + } +} + +/// Routing into a speaker layout other than stereo or ambisonic. +fn layout_dest(l: &Link<'_>, src_amb: bool) -> Matrix { + let mut m = [[0.0f32; MAX_CHANNELS]; MAX_CHANNELS]; + let (ns, nd) = (l.ns, l.nd); + let src_sp = l.src_fmt.speakers(); + let mut layout = [SpeakerPos::default(); MAX_CHANNELS]; + let nl = layout_of(l.dst_fmt, &mut layout); + let layout = layout.get(..nl).unwrap_or(&[]); + let dst_sp = l.dst_fmt.speakers(); if dst_sp.len() != nd { // No layout to pan on: channel for channel. return identity_matrix(ns.min(nd)); } + let find = |sp: Speaker| dst_sp.iter().position(|x| *x == sp).filter(|i| *i < nd); let (li, ri) = (find(Speaker::L), find(Speaker::R)); - match (ns, pan.surround) { - (1 | 2, Some(sp)) if !src_amb => { - for s in 0..ns { - let dx = if ns == 2 { if s == 0 { -1.0 } else { 1.0 } } else { 0.0 }; - soundcraft_dsp::pan::surround_gains(layout, &surround_params(sp, dx), &mut g); - for (d, gd) in g.iter().enumerate().take(nd) { - set(&mut m, s, d, *gd); - } - if let Some(lfe) = find(Speaker::Lfe) - && sp.lfe_db > -143.9 - { - set(&mut m, s, lfe, db_to_gain(sp.lfe_db.min(12.0)) / ns as f32); - } - } - } + let mut g = [0.0f32; MAX_CHANNELS]; + match (ns, l.pan.surround) { + (1 | 2, Some(sp)) if !src_amb => surround_into_layout(&mut m, l, layout, sp), (1, None) => { - let (gl, gr) = gains(pan.stereo[0], law); - if let (Some(l), Some(r)) = (li, ri) { - set(&mut m, 0, l, gl); - set(&mut m, 0, r, gr); + let (gl, gr) = gains(l.pan.stereo[0], l.law); + if let (Some(left), Some(right)) = (li, ri) { + add_gain(&mut m, 0, left, gl); + add_gain(&mut m, 0, right, gr); } } (2, None) if !src_amb => { - let (a_l, a_r) = gains(pan.stereo[0], PanLaw::Zero); - let (b_l, b_r) = gains(pan.stereo[1], PanLaw::Zero); - if let (Some(l), Some(r)) = (li, ri) { - set(&mut m, 0, l, a_l); - set(&mut m, 0, r, a_r); - set(&mut m, 1, l, b_l); - set(&mut m, 1, r, b_r); + let (a_l, a_r) = gains(l.pan.stereo[0], PanLaw::Zero); + let (b_l, b_r) = gains(l.pan.stereo[1], PanLaw::Zero); + if let (Some(left), Some(right)) = (li, ri) { + add_gain(&mut m, 0, left, a_l); + add_gain(&mut m, 0, right, a_r); + add_gain(&mut m, 1, left, b_l); + add_gain(&mut m, 1, right, b_r); } } _ if src_amb => { @@ -204,20 +252,19 @@ pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFo } let dec = soundcraft_dsp::pan::ambisonic_decode(sp.azimuth(), sp.elevation(), n_spk); for (c, gc) in dec.iter().enumerate() { - set(&mut m, c, d, *gc); + add_gain(&mut m, c, d, *gc); } } } _ if src_sp.len() == ns => { for (s, sp) in src_sp.iter().enumerate() { if let Some(d) = find(*sp) { - set(&mut m, s, d, 1.0); - } else if sp.is_lfe() { - // No LFE in the destination: dropped. - } else { + add_gain(&mut m, s, d, 1.0); + } else if !sp.is_lfe() { + // (An LFE with no LFE in the destination is dropped.) soundcraft_dsp::pan::direction_gains(layout, sp.azimuth(), sp.elevation(), &mut g); for (d, gd) in g.iter().enumerate().take(nd) { - set(&mut m, s, d, *gd); + add_gain(&mut m, s, d, *gd); } } } diff --git a/crates/mix/src/routing.rs b/crates/mix/src/routing.rs index 5b924d50..fcebca85 100644 --- a/crates/mix/src/routing.rs +++ b/crates/mix/src/routing.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{BusId, Route, Session, Track, TrackKind}; +use std::collections::HashMap; +use std::sync::Arc; /// Hash of everything `sync`/`processing_order` depend on (tracks, kinds, routing, plugins, sends). pub(crate) fn structure_fingerprint(s: &Session) -> usize { @@ -153,6 +155,7 @@ pub(crate) fn receives_solo(s: &Session, t: &Track) -> bool { #[allow(clippy::unwrap_used)] mod processing_order_tests { use super::*; + use soundcraft_model::ChannelFormat; use soundcraft_model::SendSlot; #[test] diff --git a/crates/mix/src/strip.rs b/crates/mix/src/strip.rs index 45565feb..01f4a11a 100644 --- a/crates/mix/src/strip.rs +++ b/crates/mix/src/strip.rs @@ -4,7 +4,12 @@ use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create use super::midi::{chase_controllers, schedule_notes}; use super::pan::{PanSpec, apply_matrix, build_matrix, identity_matrix}; use super::routing::receives_solo; -use super::*; +use super::{ + AutoParam, BusId, ChannelFormat, MAX_CHANNELS, PanLaw, Plugin, Range, Route, Samples, Session, SourceAudio, Track, TrackId, TrackKind, Transport, + db_to_gain, +}; +use std::collections::HashMap; +use std::sync::Arc; /// A bus: its format and one buffer per channel. pub(crate) struct BusBuf { @@ -199,131 +204,130 @@ pub(crate) struct LiveInput<'a> { pub(crate) recording: bool, } -pub(crate) fn process_strip( - s: &Session, - t: &Track, - strip: &mut Strip, - busses: &HashMap, - live: &LiveInput<'_>, - pos: Samples, - frames: usize, - any_solo: bool, -) { - for c in strip.buf.iter_mut() { +/// The block being rendered: where it starts, its length, and whether any track is soloed. +#[derive(Clone, Copy)] +pub(crate) struct Block { + pub(crate) pos: Samples, + pub(crate) frames: usize, + pub(crate) any_solo: bool, +} + +fn zero_buf(buf: &mut [Vec], frames: usize) { + for c in buf.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } - let transport = transport_at(s, pos, !live.monitor_only, live.recording); - match t.kind { - TrackKind::Audio if !live.input.is_empty() && (t.mixer.input_monitor || (t.mixer.record_arm && (live.monitor_only || live.recording))) => { - // Input monitoring: the track hears its hardware input instead of its clips. - let first = match &t.mixer.input { - Route::Hardware(h) => { - h.trim_start_matches("In ").split(['-', ' ']).next().and_then(|n| n.parse::().ok()).map_or(0, |n| n.saturating_sub(1)) - } - _ => 0, - }; - let n_in = live.input.len().max(1); - for (k, dst) in strip.buf.iter_mut().enumerate() { - if let Some(src) = live.input.get((first + k) % n_in) { - for (d, x) in dst.iter_mut().zip(src.iter()).take(frames) { - *d = *x; - } - } - } +} + +/// Input monitoring: the track hears its hardware input instead of its clips. +fn monitor_input(t: &Track, strip: &mut Strip, live: &LiveInput<'_>, frames: usize) { + let first = match &t.mixer.input { + Route::Hardware(h) => { + h.trim_start_matches("In ").split(['-', ' ']).next().and_then(|n| n.parse::().ok()).map_or(0, |n| n.saturating_sub(1)) } - TrackKind::Audio if live.monitor_only => {} - TrackKind::Instrument | TrackKind::Midi if live.monitor_only => {} - TrackKind::Audio => { - let block = Range { start: pos, end: pos.saturating_add(frames as i64) }; - for clip in t.clips() { - if clip.muted || !clip.is_audio() || !clip.range().overlaps(&block) { - continue; - } - match clip_fx_params(s, clip.id.0) { - None => render_clip(s, clip, pos, frames, &mut strip.buf, &mut strip.scratch), - Some(params) => { - let nch = strip.buf.len(); - if strip.clipbuf.len() != nch || strip.clipbuf.first().map_or(0, Vec::len) < frames { - strip.clipbuf = vec![vec![0.0; frames.max(strip.scratch.len())]; nch]; - } - for c in strip.clipbuf.iter_mut() { - c.iter_mut().take(frames).for_each(|x| *x = 0.0); - } - render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch); - let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch)); - fx.configure(params); - fx.process(&mut strip.clipbuf, frames); - for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) { - for (x, y) in d.iter_mut().zip(c.iter()).take(frames) { - *x += *y; - } - } - } - } + _ => 0, + }; + let n_in = live.input.len().max(1); + for (k, dst) in strip.buf.iter_mut().enumerate() { + if let Some(src) = live.input.get((first + k) % n_in) { + for (d, x) in dst.iter_mut().zip(src.iter()).take(frames) { + *d = *x; } } - TrackKind::Aux | TrackKind::Folder => { - if let Route::Bus(b) = &t.mixer.input - && let Some(src) = busses.get(b) - { - let (ns, nd) = (src.buf.len(), strip.buf.len()); - let dst_fmt = strip_format(t, nd); - let m = if src.fmt == dst_fmt && ns == nd { - identity_matrix(ns) - } else if ns == 1 && nd == 2 { - // A mono bus feeds both sides of a stereo aux. - let mut m = [[0.0; MAX_CHANNELS]; MAX_CHANNELS]; - m[0][0] = 1.0; - m[0][1] = 1.0; - m - } else { - let stereo = if ns == 1 { [0.0, 0.0] } else { [-1.0, 1.0] }; - build_matrix(src.fmt, ns, dst_fmt, nd, &PanSpec { stereo, surround: None }, PanLaw::Minus3) - }; - apply_matrix(&src.buf, frames, &mut strip.buf, &m, 1.0); - } - } - TrackKind::Instrument | TrackKind::Midi => { - if let Some(inst) = strip.instrument.as_mut().filter(|i| !i.failed) { - // #152: push the instrument's stored parameter values into the live plugin - // before processing, the same way inserts do below. Without this, the values - // loaded from a session (or set via `mix.insert_state`) never reach the plugin - // instance and the rendered audio matches the plugin's defaults — e.g. a saved - // `level = -12 dB` sounds the same as `level = -6 dB`. - if let Some(ins) = &t.instrument { - apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); - } - inst.plugin.set_transport(&transport); - if std::mem::take(&mut strip.chase) { - chase_controllers(s, t, pos, inst); - } - schedule_notes(s, t, pos, frames, inst, &mut strip.held); - if strip.buf.len() >= 2 { - inst.plugin.process(&mut strip.buf, frames); - } else if let Some(c0) = strip.buf.first_mut() { - // Mono MIDI track: render the instrument in stereo and fold. - let mut st = [std::mem::take(c0), std::mem::take(&mut strip.scratch)]; - inst.plugin.process(&mut st, frames); - let [a, b] = st; - *c0 = a; - strip.scratch = b; - for (x, y) in c0.iter_mut().zip(strip.scratch.iter()).take(frames) { - *x = 0.5 * (*x + *y); - } - } - if contain_failure(inst, &mut strip.buf, frames) { - strip.failures.record(INSTRUMENT_SLOT); - } - } - } - _ => {} - } - let trim = db_to_gain(t.mixer.trim_db) * if t.mixer.phase_invert { -1.0 } else { 1.0 }; - if (trim - 1.0).abs() > f32::EPSILON { - for c in strip.buf.iter_mut() { - c.iter_mut().take(frames).for_each(|x| *x *= trim); - } } +} + +/// Render the audio clips overlapping the block into the strip, through their clip effects. +fn render_audio_clips(s: &Session, t: &Track, strip: &mut Strip, blk: Block) { + let Block { pos, frames, .. } = blk; + let block = Range { start: pos, end: pos.saturating_add(frames as i64) }; + for clip in t.clips() { + if clip.muted || !clip.is_audio() || !clip.range().overlaps(&block) { + continue; + } + match clip_fx_params(s, clip.id.0) { + None => render_clip(s, clip, pos, frames, &mut strip.buf, &mut strip.scratch), + Some(params) => { + let nch = strip.buf.len(); + if strip.clipbuf.len() != nch || strip.clipbuf.first().map_or(0, Vec::len) < frames { + strip.clipbuf = vec![vec![0.0; frames.max(strip.scratch.len())]; nch]; + } + zero_buf(&mut strip.clipbuf, frames); + render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch); + let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch)); + fx.configure(params); + fx.process(&mut strip.clipbuf, frames); + for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) { + for (x, y) in d.iter_mut().zip(c.iter()).take(frames) { + *x += *y; + } + } + } + } + } +} + +/// An aux or folder reads the bus its input is routed from. +fn read_aux_bus(t: &Track, strip: &mut Strip, busses: &HashMap, frames: usize) { + if let Route::Bus(b) = &t.mixer.input + && let Some(src) = busses.get(b) + { + let (ns, nd) = (src.buf.len(), strip.buf.len()); + let dst_fmt = strip_format(t, nd); + let m = if src.fmt == dst_fmt && ns == nd { + identity_matrix(ns) + } else if ns == 1 && nd == 2 { + // A mono bus feeds both sides of a stereo aux. + let mut m = [[0.0; MAX_CHANNELS]; MAX_CHANNELS]; + m[0][0] = 1.0; + m[0][1] = 1.0; + m + } else { + let stereo = if ns == 1 { [0.0, 0.0] } else { [-1.0, 1.0] }; + build_matrix(src.fmt, ns, dst_fmt, nd, &PanSpec { stereo, surround: None }, PanLaw::Minus3) + }; + apply_matrix(&src.buf, frames, &mut strip.buf, &m, 1.0); + } +} + +/// Render the track's instrument into the strip. +fn render_instrument(s: &Session, t: &Track, strip: &mut Strip, blk: Block, transport: &Transport) { + let Block { pos, frames, .. } = blk; + if let Some(inst) = strip.instrument.as_mut().filter(|i| !i.failed) { + // #152: push the instrument's stored parameter values into the live plugin + // before processing, the same way inserts do below. Without this, the values + // loaded from a session (or set via `mix.insert_state`) never reach the plugin + // instance and the rendered audio matches the plugin's defaults — e.g. a saved + // `level = -12 dB` sounds the same as `level = -6 dB`. + if let Some(ins) = &t.instrument { + apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); + } + inst.plugin.set_transport(transport); + if std::mem::take(&mut strip.chase) { + chase_controllers(s, t, pos, inst); + } + schedule_notes(s, t, pos, frames, inst, &mut strip.held); + if strip.buf.len() >= 2 { + inst.plugin.process(&mut strip.buf, frames); + } else if let Some(c0) = strip.buf.first_mut() { + // Mono MIDI track: render the instrument in stereo and fold. + let mut st = [std::mem::take(c0), std::mem::take(&mut strip.scratch)]; + inst.plugin.process(&mut st, frames); + let [a, b] = st; + *c0 = a; + strip.scratch = b; + for (x, y) in c0.iter_mut().zip(strip.scratch.iter()).take(frames) { + *x = 0.5 * (*x + *y); + } + } + if contain_failure(inst, &mut strip.buf, frames) { + strip.failures.record(INSTRUMENT_SLOT); + } + } +} + +/// Run the active inserts in order; records their latency and the first gain reduction. +fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport) { + let Block { pos, frames, .. } = blk; let mut gr = 0.0f32; let mut latency = 0usize; for (i, ins) in t.mixer.inserts.iter().enumerate() { @@ -332,7 +336,7 @@ pub(crate) fn process_strip( continue; } apply_params(slot, ins, t, i, pos); - slot.plugin.set_transport(&transport); + slot.plugin.set_transport(transport); slot.plugin.process(&mut strip.buf, frames); if contain_failure(slot, &mut strip.buf, frames) { strip.failures.record(i); @@ -345,15 +349,11 @@ pub(crate) fn process_strip( } strip.gr = gr; strip.latency = latency.min(MAX_LATENCY); - let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t); - strip.muted = t.mixer.mute || soloed_out; - if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) { - strip.pre.resize_with(strip.buf.len(), Vec::new); - for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) { - d.clear(); - d.extend_from_slice(src.get(..frames).unwrap_or(&[])); - } - } +} + +/// Fader gain (volume, trim and VCA automation), ramped across the block. +fn apply_fader(s: &Session, t: &Track, strip: &mut Strip, blk: Block) { + let Block { pos, frames, .. } = blk; // Endpoints are the first and last sample of this block. The next block starts on its own // first sample, so a breakpoint there does not ramp the block before it. let last = pos.saturating_add(frames.saturating_sub(1) as i64); @@ -386,9 +386,13 @@ pub(crate) fn process_strip( *x *= ramp.at(i); } } - // Track mute gates the audio and its pre-fader tap without splitting processor blocks. - // Keep the existing curve interpolation, first-lane and mode policy, and render-position - // clock. Processor/bus age and Master/VCA automation policy are separate concerns. +} + +/// Track mute gates the audio and its pre-fader tap without splitting processor blocks. +/// Keep the existing curve interpolation, first-lane and mode policy, and render-position +/// clock. Processor/bus age and Master/VCA automation policy are separate concerns. +fn gate_mute_automation(t: &Track, strip: &mut Strip, blk: Block) { + let Block { pos, frames, .. } = blk; if t.mixer.automation_mode.reads() && let Some(lane) = t.lane(&AutoParam::Mute).filter(|lane| !lane.points.is_empty()) { @@ -405,6 +409,41 @@ pub(crate) fn process_strip( } } +pub(crate) fn process_strip(s: &Session, t: &Track, strip: &mut Strip, busses: &HashMap, live: &LiveInput<'_>, blk: Block) { + let Block { pos, frames, any_solo } = blk; + zero_buf(&mut strip.buf, frames); + let transport = transport_at(s, pos, !live.monitor_only, live.recording); + match t.kind { + TrackKind::Audio if !live.input.is_empty() && (t.mixer.input_monitor || (t.mixer.record_arm && (live.monitor_only || live.recording))) => { + monitor_input(t, strip, live, frames); + } + TrackKind::Audio if live.monitor_only => {} + TrackKind::Instrument | TrackKind::Midi if live.monitor_only => {} + TrackKind::Audio => render_audio_clips(s, t, strip, blk), + TrackKind::Aux | TrackKind::Folder => read_aux_bus(t, strip, busses, frames), + TrackKind::Instrument | TrackKind::Midi => render_instrument(s, t, strip, blk, &transport), + _ => {} + } + let trim = db_to_gain(t.mixer.trim_db) * if t.mixer.phase_invert { -1.0 } else { 1.0 }; + if (trim - 1.0).abs() > f32::EPSILON { + for c in strip.buf.iter_mut() { + c.iter_mut().take(frames).for_each(|x| *x *= trim); + } + } + run_inserts(t, strip, blk, &transport); + let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t); + strip.muted = t.mixer.mute || soloed_out; + if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) { + strip.pre.resize_with(strip.buf.len(), Vec::new); + for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) { + d.clear(); + d.extend_from_slice(src.get(..frames).unwrap_or(&[])); + } + } + apply_fader(s, t, strip, blk); + gate_mute_automation(t, strip, blk); +} + /// Silences a block the plugin filled with NaN or infinity and takes the plugin out until the /// next [`MixEngine::reset`]. True then. pub(crate) fn contain_failure(slot: &mut PluginSlot, buf: &mut [Vec], frames: usize) -> bool { diff --git a/crates/mix/src/tests.rs b/crates/mix/src/tests.rs index 5ebc6db1..5508f764 100644 --- a/crates/mix/src/tests.rs +++ b/crates/mix/src/tests.rs @@ -224,6 +224,15 @@ fn sends_feed_aux_via_bus() { assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]); } +#[test] +fn send_level_automation_is_capped_at_the_fader_top() { + // #884: a send lane point past +12 dB (pushed without `set_point`'s clamp) reads as +12. + let (mut s, t) = session_with_dc(0.5, 4800); + let tr = s.track_mut(t).unwrap(); + tr.lane_mut(&AutoParam::SendLevel(0)).points.push(soundcraft_model::AutomationPoint { at: 0, value: 500.0 }); + assert_eq!(send_level(s.track(t).unwrap(), 0, 0.0, 100), SEND_LEVEL_MAX_DB); +} + #[test] fn render_bus_is_the_bus_not_the_main_mix() { let (mut s, t) = session_with_dc(0.5, 4800); @@ -1187,3 +1196,15 @@ fn apply_matrix_span_matches_the_plain_per_sample_sum() { } } } + +#[test] +fn gain_ramp_matches_converting_both_ends_every_sample() { + for (db0, db1, n) in [(-6.0f32, 3.0f32, 512usize), (0.0, 0.0, 1), (-144.0, 0.0, 300), (2.5, -60.0, 0), (f32::NAN, -3.0, 64), (-200.0, 12.0, 7)] { + let ramp = crate::automation::GainRamp::new(db0, db1, n); + let (g0, g1) = (db_to_gain(db0), db_to_gain(db1)); + for i in 0..=n { + let want = if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) }; + assert_eq!(ramp.at(i).to_bits(), want.to_bits(), "{db0} -> {db1} dB, sample {i} of {n}"); + } + } +} diff --git a/crates/mix/src/threads.rs b/crates/mix/src/threads.rs index cfb2dd98..c71fb3fc 100644 --- a/crates/mix/src/threads.rs +++ b/crates/mix/src/threads.rs @@ -1,4 +1,4 @@ -use super::*; +use super::AUDIO_THREAD; /// Mark the calling thread as the realtime audio thread (`soundcraft_playback`'s cpal callbacks /// do, every call: one thread-local store). Strips [`MixEngine::render`] hands to worker threads diff --git a/crates/model/src/automation.rs b/crates/model/src/automation.rs index 26bbcae3..a46dc47a 100644 --- a/crates/model/src/automation.rs +++ b/crates/model/src/automation.rs @@ -80,7 +80,8 @@ impl AutoParam { } /// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`. pub fn parse(s: &str) -> Option { - let s = s.trim().to_ascii_lowercase(); + let orig = s.trim(); + let s = orig.to_ascii_lowercase(); match s.as_str() { "volume" | "vol" => return Some(AutoParam::Volume), "pan" | "pan1" => return Some(AutoParam::Pan(0)), @@ -100,6 +101,8 @@ impl AutoParam { } if let Some(rest) = s.strip_prefix("plugin:") { let (slot, param) = rest.split_once(':')?; + // ASCII lowercasing keeps byte offsets, so take the param from the original. + let param = orig.get(orig.len() - param.len()..)?; return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() }); } None @@ -158,7 +161,10 @@ impl AutomationLane { if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) { return a.value; } - a.value + (b.value - a.value) * ((at - a.at) as f32 / (b.at - a.at) as f32) + // Widen so far-apart breakpoints cannot overflow i64. + let span = i128::from(b.at) - i128::from(a.at); + let off = i128::from(at) - i128::from(a.at); + a.value + (b.value - a.value) * (off as f64 / span as f64) as f32 } (Some(a), _) => a.value, _ => default, @@ -255,6 +261,26 @@ impl AutomationLane { mod tests { use super::*; + #[test] + fn parse_keeps_plugin_param_case() { + let p = AutoParam::Plugin { slot: 0, param: "Cutoff".into() }; + assert_eq!(AutoParam::parse("plugin:a:Cutoff"), Some(p)); + assert_eq!(AutoParam::parse("Plugin:B:Mix"), Some(AutoParam::Plugin { slot: 1, param: "Mix".into() })); + assert_eq!(AutoParam::parse("Volume"), Some(AutoParam::Volume)); + assert_eq!(AutoParam::parse("SEND_B_LEVEL"), Some(AutoParam::SendLevel(1))); + } + + #[test] + fn value_at_far_apart_points_does_not_overflow() { + let mut l = AutomationLane::new(AutoParam::Volume); + l.set_point(-1, 0.0); + l.set_point(i64::MAX, -10.0); + let v = l.value_at(5, 0.0); + assert!(v.is_finite() && (-10.0..=0.0).contains(&v)); + let w = l.value_at(i64::MAX - 1, 0.0); + assert!(w.is_finite() && (-10.0..=0.0).contains(&w)); + } + #[test] fn pan_label_max_does_not_overflow() { assert_eq!(AutoParam::Pan(255).label(), "pan 256"); diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 5283ff83..03080016 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -626,6 +626,8 @@ impl Session { } for l in &mut t.automation { l.points.retain(|p| p.value.is_finite()); + let (lo, hi, _) = l.param.range(); + l.points.iter_mut().for_each(|p| p.value = p.value.clamp(lo, hi)); l.points.iter_mut().for_each(|p| bound(&mut p.at)); l.points.sort_by_key(|p| p.at); } @@ -905,6 +907,22 @@ mod tests { assert!(s.next_id > t.0); } + #[test] + fn loaded_automation_is_clamped_to_its_range() { + // #884: a session file can carry points outside a lane's range; load clamps them. + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let tr = s.track_mut(t).unwrap(); + for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] { + tr.lane_mut(&p).points.push(crate::AutomationPoint { at: 0, value: 500.0 }); + } + let s = Session::from_json(&serde_json::to_string(&s).unwrap()).unwrap(); + let tr = s.track(t).unwrap(); + for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] { + assert_eq!(tr.lane(&p).unwrap().points[0].value, 12.0, "{p:?}"); + } + } + #[test] fn a_loaded_auto_name_scheme_leaves_room_for_its_number() { let mut scheme = crate::AutoName { name: "n".repeat(300), suffix: "s".repeat(300), ..Default::default() }; diff --git a/crates/playback/src/callback.rs b/crates/playback/src/callback.rs index fc89c16e..5e931fe0 100644 --- a/crates/playback/src/callback.rs +++ b/crates/playback/src/callback.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, AtomicBool, BLOCK, BTreeMap, Cmd, HashMap, HashSet, MAX_CHANNELS, MeterSnapshot, MixEngine, OLD_SESSIONS, Ordering, OutputTrouble, Plugin, + PluginEditor, PluginStateReader, PluginStates, PreparedInstance, Range, Receiver, Samples, Sender, Session, Shared, SyncSender, TrackId, + itu_stereo_fold, main_channels, record, sync_channel, +}; /// How the audio output is doing, for the UI. #[derive(Default)] diff --git a/crates/playback/src/stream.rs b/crates/playback/src/stream.rs index af90a912..a1f78530 100644 --- a/crates/playback/src/stream.rs +++ b/crates/playback/src/stream.rs @@ -1,5 +1,5 @@ use super::callback::{AudioState, Fault, guarded_block}; -use super::*; +use super::{Arc, AudioConfig, Cmd, Ordering, Plugin, Receiver, Sender, Shared, devices, mark_audio_thread}; /// Start a freshly built output stream. `false` when the device refuses to start it, so the caller /// treats it like a stream that failed to build and tries the next configuration. diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index f59611b0..3268ef0f 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -115,7 +115,19 @@ pub fn parse_position( let v = sr.samples(m * 60.0 + s); Ok(if neg { -v } else { v }) } - TimeFormat::Timecode => Ok(Timecode::parse(t)?.to_samples(rate, sr).saturating_sub(tc_start)), + TimeFormat::Timecode => { + let tc = Timecode::parse(t)?; + if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) { + return Err(err()); + } + if rate.is_drop() && tc.seconds == 0 && tc.minutes % 10 != 0 { + let dropped = if rate.nominal() >= 60 { 4 } else { 2 }; + if tc.frames < dropped { + return Err(err()); + } + } + Ok(tc.to_samples(rate, sr).saturating_sub(tc_start)) + } TimeFormat::FeetFrames => { let (ft, fr) = t.split_once('+').ok_or_else(err)?; let ft: i64 = ft.trim().parse().map_err(|_| err())?; @@ -207,6 +219,20 @@ mod tests { assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err()); } + #[test] + fn drop_frame_skipped_labels_are_rejected() { + let (sr, map, _) = ctx(); + for (rate, skipped) in [ + (FrameRate::Fps29_97Drop, &["00:01:00;00", "00:01:00;01"][..]), + (FrameRate::Fps59_94Drop, &["00:01:00;00", "00:01:00;01", "00:01:00;02", "00:01:00;03"][..]), + ] { + for text in skipped { + assert!(parse_position(text, TimeFormat::Timecode, sr, &map, rate, 0, 0).is_err(), "accepted skipped label {text} at {rate:?}"); + } + assert!(parse_position("00:10:00;00", TimeFormat::Timecode, sr, &map, rate, 0, 0).is_ok()); + } + } + #[test] fn bar_lengths() { let (sr, map, rate) = ctx(); diff --git a/crates/time/src/grid.rs b/crates/time/src/grid.rs index 13551b52..a78c600e 100644 --- a/crates/time/src/grid.rs +++ b/crates/time/src/grid.rs @@ -122,7 +122,7 @@ impl GridValue { GridValue::Note { value, dotted, triplet } => { let t0 = map.samples_to_ticks(at, sr); let dt = Self::note_ticks(value, dotted, triplet, map, t0); - (map.tick_to_samples(t0 + dt, sr) - map.tick_to_samples(t0, sr)).max(1) + (map.tick_to_samples(t0.saturating_add(dt), sr).saturating_sub(map.tick_to_samples(t0, sr))).max(1) } GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1), GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1), @@ -320,4 +320,11 @@ mod tests { assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10)); assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1)); } + + #[test] + fn snap_at_extreme_position_does_not_overflow() { + let sr = SampleRate::new(8000).unwrap(); + let map = TempoMap::new(1000.0, 4, 4).unwrap(); + let _ = GridValue::default().snap(i64::MAX, sr, &map, FrameRate::Fps30); + } } diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 6aaa50b8..23bb2be0 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -251,10 +251,10 @@ impl TempoMap { for (i, m) in self.meters.iter().enumerate() { let next_tick = self.meters.get(i + 1).map(|n| n.tick); let tpb = m.ticks_per_bar(); - let bars_in_seg = next_tick.map(|nt| ((nt - cur_tick) + tpb - 1) / tpb); + let bars_in_seg = next_tick.map(|nt| ceil_div(nt.saturating_sub(cur_tick), tpb)); match bars_in_seg { - Some(n) if bar >= cur_bar + n => { - cur_bar += n; + Some(n) if bar >= cur_bar.saturating_add(n) => { + cur_bar = cur_bar.saturating_add(n); cur_tick = next_tick.unwrap_or(cur_tick); } _ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)), @@ -284,7 +284,7 @@ impl TempoMap { if let Some(n) = self.meters.get(i + 1) && tick >= n.tick { - bar += ((n.tick - seg_tick) + tpb - 1) / tpb; + bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb)); seg_tick = n.tick; continue; } @@ -301,7 +301,7 @@ impl TempoMap { pub fn tick_at_bar_beat(&self, bb: BarBeat) -> i64 { let start = self.bar_start_tick(bb.bar); let m = self.meter_at_tick(start); - start.saturating_add((bb.beat - 1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick) + start.saturating_add(bb.beat.saturating_sub(1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick) } pub fn bar_beat_at(&self, s: Samples, sr: SampleRate) -> BarBeat { @@ -323,6 +323,11 @@ impl TempoMap { } } +/// Ceiling division for a non-negative `num` and positive `den`, without overflow. +fn ceil_div(num: i64, den: i64) -> i64 { + num / den + i64::from(num % den != 0) +} + #[cfg(test)] mod tests { use super::*; @@ -337,6 +342,16 @@ mod tests { assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 }); } + #[test] + fn meter_near_max_tick_does_not_overflow() { + let mut m = TempoMap::default(); + m.set_meter(i64::MAX, 7, 8).unwrap(); + let bb = m.bar_beat_at_tick(i64::MAX); + assert!(bb.bar > 1); + assert!(m.bar_start_tick(bb.bar) > 0); + assert!(m.bar_start_tick(i64::MAX) > 0); + } + #[test] fn minimum_bar_saturates_instead_of_overflowing() { let m = TempoMap::default(); @@ -344,6 +359,12 @@ mod tests { assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4); } + #[test] + fn minimum_beat_is_clamped_without_overflow() { + let m = TempoMap::default(); + assert_eq!(m.tick_at_bar_beat(BarBeat { bar: 1, beat: i64::MIN, tick: 0 }), 0); + } + #[test] fn bar_beat_at_min_tick_does_not_overflow() { let m = TempoMap::default(); diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007fb..3116b01d 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,8 +1,7 @@ -//! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. //! -//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules -//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml +//! entry is verified. //! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! JSON says the entry was verified (`people_entry`). //! diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b0807213..0be771ba 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -1,6 +1,10 @@ use super::services::Services; use super::state::MainWindow; -use super::*; +use super::{ + ControlRequest, Engine, Gesture, HashMap, MeterDisplay, OutputTrouble, Player, Range, Receiver, Samples, SoundApp, TrackId, TransportRequest, + Trouble, UiState, Value, VecDeque, bounce, dialogs, edit_window, extra_windows, fonts, import, json, menus, midi_editor, mix_window, ops_windows, + palette, panels, recovery, sampler_window, score_editor, shortcuts, theme, updates_ui, video_track, video_window, windows, +}; mod audio; mod commands; @@ -101,6 +105,7 @@ impl SoundApp { quit_requested: false, arrange_request: false, touched: std::collections::HashSet::new(), + touched_sends: std::collections::HashSet::new(), last_write_at: i64::MIN, editor_states: Vec::new(), editor_poll_due: true, @@ -155,7 +160,7 @@ impl SoundApp { if hidden { self.pointer_down = false; self.wants_keyboard = false; - if matches!(self.gesture, Some(Gesture::Fader { .. })) { + if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) { self.gesture = None; } } @@ -172,15 +177,14 @@ impl SoundApp { if ctx.current_pass_index() == 0 { bounce::step(self, ctx); import::step(self, ctx); - #[cfg(not(target_arch = "wasm32"))] updates_ui::tick(self, ctx); } if !self.pointer_down { - if matches!(self.gesture, Some(Gesture::Fader { .. })) { + if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) { self.gesture = None; } // Keep a running automation pass merged until playback stops. - if !self.wants_keyboard && self.touched.is_empty() { + if !self.wants_keyboard && self.touched.is_empty() && self.touched_sends.is_empty() { self.engine.end_merge(); } } @@ -336,7 +340,6 @@ impl SoundApp { score_editor::show(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); - #[cfg(not(target_arch = "wasm32"))] updates_ui::show(self, &ctx); // The sampler's drop rects are current now, so route any captured drop in this pass. self.route_dropped_files(); diff --git a/crates/ui-egui/src/app/audio.rs b/crates/ui-egui/src/app/audio.rs index 7a9f9bf1..3ec92849 100644 --- a/crates/ui-egui/src/app/audio.rs +++ b/crates/ui-egui/src/app/audio.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{MeterDisplay, OutputTrouble, Player, SoundApp, Trouble}; impl SoundApp { /// Whether the meters are live: while playing, and while armed or input-monitored tracks are heard. diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 2ae62f9b..42a74ce8 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, Value, bounce, menus}; impl SoundApp { /// Run a command; errors go to the status line. Returns the result. @@ -30,6 +30,9 @@ impl SoundApp { // Re-selecting a scrolling mode resumes the playhead follow. self.edit_layout.follow_hold = false; } + if id == "view.zoom_toggle_cancel" && v["canceled"] == true { + self.edit_layout.forget_zoom_back(); + } if id == "track.clear_clip_indicators" { self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false); } diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index ab5f91a6..1ba17caf 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, Value, json}; use crate::control; impl SoundApp { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 22ce03dd..6b230d34 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, midi_editor}; impl SoundApp { /// The open document was replaced outside [`Self::run`] (Recover, a session the browser read diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index cafbed86..c6167d5a 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, json, sampler_window}; impl SoundApp { /// Feed queued synthetic input (from the control channel) into egui: one pointer event per diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index fd17adc4..ccb9e742 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -1,6 +1,6 @@ use super::audio::feed_meter; use super::plugins::failed_plugin_status; -use super::*; +use super::{Gesture, MainWindow, MeterDisplay, Range, Samples, SoundApp, TrackId, TransportRequest, json}; /// Whether another automation-write pass is due: the transport moved at least /// `step` since `last` in either direction. `last == i64::MIN` marks "never @@ -204,7 +204,8 @@ impl SoundApp { } /// While playing, record volume automation from fader positions for tracks in Write mode, and - /// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Each point replaces the + /// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Send levels follow the + /// same rules, per send, from its slot or its floating send window. Each point replaces the /// automation since the previous one, and Stop writes a last point. A pass and whatever is /// dragged during it, its fader included, are one undo step. pub(crate) fn write_automation(&mut self) { @@ -214,7 +215,7 @@ impl SoundApp { let may_write = playing && self.engine.session().tracks.iter().any(|t| matches!(t.mixer.automation_mode, M::Write | M::Touch | M::Latch | M::TouchLatch)); self.engine.hold_merge(may_write.then_some(PASS)); - if !playing && self.touched.is_empty() { + if !playing && self.touched.is_empty() && self.touched_sends.is_empty() { self.last_write_at = i64::MIN; return; } @@ -230,6 +231,10 @@ impl SoundApp { Some(Gesture::Fader { track, .. }) => Some(*track), _ => None, }; + let send_fader = match &self.gesture { + Some(Gesture::SendFader { track, slot }) => Some((*track, *slot)), + _ => None, + }; let writes: Vec<(TrackId, f32)> = self .engine .session() @@ -247,9 +252,39 @@ impl SoundApp { write.then_some((t.id, t.mixer.volume_db)) }) .collect(); - for (t, v) in writes { + let send_writes: Vec<(TrackId, usize, f32)> = self + .engine + .session() + .tracks + .iter() + .flat_map(|t| { + let m = t.mixer.automation_mode; + t.mixer.sends.iter().enumerate().filter_map(move |(slot, s)| s.as_ref().map(|s| (t.id, m, slot, s.level_db))) + }) + .filter(|&(t, m, slot, _)| { + let touched_now = dragging && send_fader == Some((t, slot)); + match m { + M::Write => true, + M::Touch => touched_now, + M::Latch | M::TouchLatch => touched_now || self.touched_sends.contains(&(t, slot)), + _ => false, + } + }) + .map(|(t, _, slot, db)| (t, slot, db)) + .collect(); + let points: Vec<(TrackId, String, f32)> = writes + .iter() + .map(|&(t, v)| (t, "volume".to_string(), v)) + .chain(send_writes.iter().map(|&(t, slot, v)| (t, format!("send_{}_level", (b'a' + slot as u8) as char), v))) + .collect(); + for &(t, _) in &writes { self.touched.insert(t); - let mut point = serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v}); + } + for &(t, slot, _) in &send_writes { + self.touched_sends.insert((t, slot)); + } + for (t, param, v) in points { + let mut point = serde_json::json!({"track": t.0, "param": param, "at": pos, "value": v}); if from != i64::MIN { point["from"] = from.into(); } @@ -257,6 +292,7 @@ impl SoundApp { } if !playing { self.touched.clear(); + self.touched_sends.clear(); self.engine.end_merge(); } } @@ -296,6 +332,7 @@ impl SoundApp { self.punch = None; self.gesture = None; self.touched.clear(); + self.touched_sends.clear(); self.last_write_at = i64::MIN; self.edit_layout.follow_hold = false; self.meters.clear(); @@ -306,6 +343,21 @@ impl SoundApp { self.play_from_selection(); } } + TransportRequest::PlayFrom(at) => { + if self.is_playing() { + // Playing on from `at` drops the end and loop of the earlier selection. + if let Some(p) = &self.player { + p.play(at, None, None); + } + if let Some(sim) = &mut self.sim { + *sim = (at, None, None); + } + self.last_write_at = i64::MIN; + self.engine.transport.position = at; + } else { + self.start_play(at, None, None); + } + } TransportRequest::Stop | TransportRequest::Pause => self.stop_play(), TransportRequest::TogglePlay => { if self.is_playing() { diff --git a/crates/ui-egui/src/app/plugins.rs b/crates/ui-egui/src/app/plugins.rs index da316f9b..19feaee7 100644 --- a/crates/ui-egui/src/app/plugins.rs +++ b/crates/ui-egui/src/app/plugins.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, TrackId, json, mix_window}; /// The plugins whose state the session takes this frame, out of those changed in their editors /// (`pending`, kept across frames). A plugin editor doesn't say when a gesture ends: a plugin's diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index c22c4720..42804e02 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -1,6 +1,6 @@ //! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by -//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is +//! build.rs from contributors/contributors.json; nothing is //! read at run time. use std::cmp::Ordering; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 17543691..f9e76af2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -23,6 +23,7 @@ use files::{OPEN_PENDING, default_dir, open_pending_session, path_dialog}; use strip_silence::{AUTO_NAME_LABELS, STRIP_NAME_LABELS, StripRectangles, auto_rename_action, strip_name_fields, strip_silence_action}; pub use strip_silence::{StripRects, strip_silence_rectangles}; +pub mod new_tracks; pub mod ops_windows; pub(super) mod recent; pub mod recovery; @@ -30,7 +31,7 @@ pub mod spot; #[derive(Debug, Clone)] pub enum Dialog { - NewTracks { count: u32, format: String, kind: String, timebase: String, name: String }, + NewTracks(new_tracks::NewTracks), NewSession { name: String, sample_rate: u32, demo: bool }, Bounce { path: String, format: FileFormat, bit_depth: String, normalize: bool, dither: bool }, RenameTrack { id: TrackId, name: String }, @@ -82,9 +83,10 @@ pub struct Dialogs { } impl Dialogs { + /// Returns the stable UI identifier of the currently open dialog. pub fn open_name(&self) -> Option<&'static str> { self.open.as_ref().map(|d| match d { - Dialog::NewTracks { .. } => "new_tracks", + Dialog::NewTracks(_) => "new_tracks", Dialog::NewSession { .. } => "new_session", Dialog::Bounce { .. } => "bounce", Dialog::RenameTrack { .. } => "rename_track", @@ -166,9 +168,7 @@ impl Dialogs { let ed = &e.session().edit; return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone()); } - "track.new" => { - Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } - } + "track.new" => Dialog::NewTracks(new_tracks::NewTracks::default()), "session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false }, "file.bounce_mix" => { let dir = e @@ -281,14 +281,140 @@ fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option< Some(text) } +/// The Bounce form's file row: a path field and a Browse button that also picks the format. +fn bounce_file_row(app: &mut SoundApp, ui: &mut egui::Ui, path: &mut String, format: &mut FileFormat) { + ui.horizontal(|ui| { + ui.label("File"); + crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "File"); + if let Some(pick) = &app.services.pick_save + && ui.button("Browse…").clicked() + && let Some(p) = pick("file.bounce_mix", std::path::Path::new(path.as_str())) + { + match unicode(&p) { + Ok(text) => { + *path = text.to_owned(); + if let Some(picked) = p.extension().and_then(std::ffi::OsStr::to_str).and_then(FileFormat::from_write_extension) { + *format = picked; + } + } + Err(e) => app.ui.status = e.to_string(), + } + } + }); +} + +/// The path prompt's form; the command to run once it is confirmed. +fn path_prompt_form(app: &mut SoundApp, ui: &mut egui::Ui, cmd: &str, path: &mut String, key: &str, enter: bool) -> Option<(String, Value)> { + crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "Path"); + let mut picked = None; + if let Some(pick) = &app.services.pick_open + && cmd != "session.save_as" + && cmd != "session.save_copy" + && key != "dir" + && ui.button("Browse…").clicked() + && let Some(p) = pick(cmd, &[]) + { + picked = Some(p); + } + if let Some(pick) = &app.services.pick_folder + && key == "dir" + && ui.button("Browse…").clicked() + && let Some(p) = pick(std::path::Path::new(path.as_str())) + { + picked = Some(p); + } + if let Some(pick) = &app.services.pick_save + && (cmd == "session.save_as" || cmd == "session.save_copy") + && ui.button("Browse…").clicked() + && let Some(p) = pick(cmd, std::path::Path::new(path.as_str())) + { + picked = Some(p); + } + match picked.as_deref().map(unicode) { + Some(Ok(text)) => *path = text.to_owned(), + Some(Err(e)) => app.ui.status = e.to_string(), + None => {} + } + let file = soundcraft_engine::io::session_file(path); + let replaces = soundcraft_engine::io::replaces(&app.engine, cmd, &file); + if replaces { + ui.label(format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy())); + } + if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) { + let mut v = json!({}); + v[key] = json!(path); + if replaces { + v["replace"] = json!(true); + } + return Some((cmd.to_string(), v)); + } + None +} + +/// The Strip Silence form: the Strip Silence command to run, or the values to carry into Rename. +fn strip_silence_form( + ui: &mut egui::Ui, + values: &mut [f64; 4], + strip_names: &StripNames, + enter: bool, +) -> (Option<(String, Value)>, Option<[f64; 4]>) { + let mut action = None; + let mut rename_from = None; + let [threshold, min_ms, pre_ms, post_ms] = values; + let before = [*threshold, *min_ms, *pre_ms, *post_ms]; + ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB")); + ui.add(egui::Slider::new(min_ms, 0.0..=4000.0).text("Min strip duration ms")); + ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms")); + ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms")); + let values = [*threshold, *min_ms, *pre_ms, *post_ms]; + // The Edit window is drawn before this window, so it draws the rectangles for new values in the next frame. + if values != before { + ui.ctx().request_repaint(); + } + if !strip_names.name.trim().is_empty() { + let names = format!("New clips are named {}, {} and so on.", strip_names.clip_name(0), strip_names.clip_name(1)); + ui.label(egui::RichText::new(names).small().color(Tokens::current().text_dim)); + } + ui.separator(); + ui.horizontal(|ui| { + let mut mode = None; + let mut rename = false; + if ui.button("Extract").clicked() { + mode = Some("extract"); + } + if ui.button("Rename…").clicked() { + rename = true; + } + if ui.button("Separate").clicked() { + mode = Some("separate"); + } + // Enter on a focused Extract or Separate clicks that button, so Enter presses Strip only when neither was clicked. + if buttons(ui, "Strip", enter) && mode.is_none() { + mode = Some("strip"); + } + // Enter on a focused Rename… also presses Strip, so Rename… goes first. + if rename { + rename_from = Some(values); + } else if let Some(mode) = mode { + action = Some(strip_silence_action(mode, values, strip_names)); + } + }); + (action, rename_from) +} + /// Show the open dialog, if any. pub fn show(app: &mut SoundApp, ctx: &egui::Context) { audiosuite_window(app, ctx); crate::bounce::show(app, ctx); crate::import::show(app, ctx); + // Whichever window shows, closing Strip Silence drops its rectangles and stops their scan. + if !matches!(app.dialogs.open, Some(Dialog::StripSilence { .. } | Dialog::StripSilenceRename { .. })) { + app.dialogs.strip_rectangles = StripRectangles::default(); + } let Some(mut d) = app.dialogs.open.take() else { return }; let mut keep = true; let mut action: Option<(String, Value)> = None; + let mut bounce_params: Option = None; let mut save_first: Option<(String, Value)> = None; let confirmed = matches!(d, Dialog::SaveChanges { .. }); // Strip Silence's Rename button, and the naming scheme its dialog's OK keeps. @@ -296,7 +422,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { let mut renamed: Option = None; let strip_names = app.dialogs.strip_names.clone(); let title = match &d { - Dialog::NewTracks { .. } => "New Tracks", + Dialog::NewTracks(_) => "New Tracks", Dialog::NewSession { .. } => "New Session", Dialog::Bounce { .. } => "Bounce Mix", Dialog::RenameTrack { .. } => "Rename Track", @@ -325,47 +451,15 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { .resizable(false) .anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0)) .show(ctx, |ui| { + // Escape owns the whole frame: no focused button or other control may act first. + if esc { + return; + } ui.spacing_mut().item_spacing = vec2(8.0, 8.0); match &mut d { - Dialog::NewTracks { count, format, kind, timebase, name } => { - ui.horizontal(|ui| { - let create = ui.label("Create"); - ui.add(egui::DragValue::new(count).range(1..=128)).labelled_by(create.id); - ui.label("new"); - let fmt = craft_ui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| { - for f in soundcraft_model::ChannelFormat::ALL { - ui.selectable_value(format, f.label().to_string(), f.label()); - } - }); - fmt.response.accessible_name("Channel format"); - let kind_combo = craft_ui::ComboBox::from_id_salt("nt_kind") - .selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label())) - .show_ui(ui, |ui| { - for k in soundcraft_model::TrackKind::ALL { - if ui.selectable_label(kind == k.id(), k.label()).clicked() { - *kind = k.id().to_string(); - *name = k.default_name().to_string(); - } - } - }); - kind_combo.response.accessible_name("Track kind"); - ui.label("in"); - let tb = craft_ui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui( - ui, - |ui| { - ui.selectable_value(timebase, "samples".to_string(), "Samples"); - ui.selectable_value(timebase, "ticks".to_string(), "Ticks"); - }, - ); - tb.response.accessible_name("Timebase"); - }); - ui.horizontal(|ui| { - ui.label("Name:"); - crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name"); - }); - if buttons(ui, "Create", enter) { - action = - Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name}))); + Dialog::NewTracks(tracks) => { + if let Some(params) = tracks.show(ui, enter) { + action = Some(("track.new".into(), params)); } } Dialog::NewSession { name, sample_rate, demo } => { @@ -387,24 +481,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::Bounce { path, format, bit_depth, normalize, dither } => { - ui.horizontal(|ui| { - ui.label("File"); - crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "File"); - if let Some(pick) = &app.services.pick_save - && ui.button("Browse…").clicked() - && let Some(p) = pick("file.bounce_mix", std::path::Path::new(path.as_str())) - { - match unicode(&p) { - Ok(text) => { - *path = text.to_owned(); - if let Some(picked) = p.extension().and_then(std::ffi::OsStr::to_str).and_then(FileFormat::from_write_extension) { - *format = picked; - } - } - Err(e) => app.ui.status = e.to_string(), - } - } - }); + bounce_file_row(app, ui, path, format); ui.horizontal(|ui| { craft_ui::ComboBox::from_label("Format").selected_text(format.name()).show_ui(ui, |ui| { for &f in FileFormat::WRITE { @@ -430,11 +507,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ); if buttons(ui, "Bounce", enter) { let p = std::path::Path::new(path.as_str()).with_extension(format.default_extension()); - crate::bounce::start( - app, - &json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}), + bounce_params = Some( + json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}), ); - keep = false; } } Dialog::RenameTrack { id, name } => { @@ -463,48 +538,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::PathPrompt { cmd, path, key, .. } => { - crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "Path"); - let mut picked = None; - if let Some(pick) = &app.services.pick_open - && cmd != "session.save_as" - && cmd != "session.save_copy" - && key != "dir" - && ui.button("Browse…").clicked() - && let Some(p) = pick(cmd, &[]) - { - picked = Some(p); - } - if let Some(pick) = &app.services.pick_folder - && key == "dir" - && ui.button("Browse…").clicked() - && let Some(p) = pick(std::path::Path::new(path.as_str())) - { - picked = Some(p); - } - if let Some(pick) = &app.services.pick_save - && (cmd == "session.save_as" || cmd == "session.save_copy") - && ui.button("Browse…").clicked() - && let Some(p) = pick(cmd, std::path::Path::new(path.as_str())) - { - picked = Some(p); - } - match picked.as_deref().map(unicode) { - Some(Ok(text)) => *path = text.to_owned(), - Some(Err(e)) => app.ui.status = e.to_string(), - None => {} - } - let file = soundcraft_engine::io::session_file(path); - let replaces = soundcraft_engine::io::replaces(&app.engine, cmd, &file); - if replaces { - ui.label(format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy())); - } - if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) { - let mut v = json!({}); - v[key.as_str()] = json!(path); - if replaces { - v["replace"] = json!(true); - } - action = Some((cmd.clone(), v)); + if let Some(c) = path_prompt_form(app, ui, cmd, path, key, enter) { + action = Some(c); } } Dialog::Number { cmd, key, value, suffix, .. } => { @@ -543,44 +578,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => { - let before = [*threshold, *min_ms, *pre_ms, *post_ms]; - ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB")); - ui.add(egui::Slider::new(min_ms, 0.0..=4000.0).text("Min strip duration ms")); - ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms")); - ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms")); - let values = [*threshold, *min_ms, *pre_ms, *post_ms]; - // The Edit window is drawn before this window, so it draws the rectangles for new values in the next frame. - if values != before { - ui.ctx().request_repaint(); - } - if !strip_names.name.trim().is_empty() { - let names = format!("New clips are named {}, {} and so on.", strip_names.clip_name(0), strip_names.clip_name(1)); - ui.label(egui::RichText::new(names).small().color(Tokens::current().text_dim)); - } - ui.separator(); - ui.horizontal(|ui| { - let mut mode = None; - let mut rename = false; - if ui.button("Extract").clicked() { - mode = Some("extract"); - } - if ui.button("Rename…").clicked() { - rename = true; - } - if ui.button("Separate").clicked() { - mode = Some("separate"); - } - // Enter on a focused Extract or Separate clicks that button, so Enter presses Strip only when neither was clicked. - if buttons(ui, "Strip", enter) && mode.is_none() { - mode = Some("strip"); - } - // Enter on a focused Rename… also presses Strip, so Rename… goes first. - if rename { - rename_from = Some(values); - } else if let Some(mode) = mode { - action = Some(strip_silence_action(mode, values, &strip_names)); - } - }); + let mut values = [*threshold, *min_ms, *pre_ms, *post_ms]; + let (a, r) = strip_silence_form(ui, &mut values, &strip_names, enter); + [*threshold, *min_ms, *pre_ms, *post_ms] = values; + action = a.or(action.take()); + rename_from = r.or(rename_from); } Dialog::StripSilenceRename { names, .. } => { strip_name_fields(ui, names, STRIP_NAME_LABELS); @@ -712,14 +714,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { }); if esc || ctx.memory(|m| m.data.get_temp::(egui::Id::unique("dlg_cancel")).unwrap_or(false)) { keep = false; + action = None; + bounce_params = None; + save_first = None; app.dialogs.after_save = None; app.dialogs.pending_session = None; ctx.memory_mut(|m| m.data.remove::(egui::Id::unique("dlg_cancel"))); } - // Enter on a focused Cancel also presses OK. In Auto Rename, the dialog then closes without running the command. - if !keep && matches!(d, Dialog::AutoRename { .. }) { - action = None; - } if let Some(strip) = rename_from && keep { @@ -747,6 +748,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } app.dialogs.after_save = Some(then); } + if let Some(params) = bounce_params { + keep = false; + crate::bounce::start(app, ¶ms); + } if let Some((cmd, params)) = &action && !confirmed && ask_to_save(app, cmd, params) @@ -797,11 +802,13 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { } /// `buttons`, but a greyed-out OK when the dialog's values can't be submitted yet. Cancel always works. -fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool { +pub(crate) fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool { let mut pressed = enter && ready; + let mut canceled = false; ui.separator(); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { + canceled = true; ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::unique("dlg_cancel"), true)); } let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent); @@ -809,7 +816,7 @@ fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> boo pressed = true; } }); - pressed + pressed && !canceled } /// A name field that takes the focus with its text selected, so typing replaces the old name. @@ -868,6 +875,238 @@ fn loop_length(s: &Session, text: &str, format: soundcraft_time::TimeFormat, sta #[cfg(test)] mod tests { use super::*; + use crate::dialogs::new_tracks::set_new_track_kind; + + /// Opens and settles the dialog for a chosen desktop operating system. + fn tracks_dialog(os: egui::os::OperatingSystem) -> (egui::Context, SoundApp) { + let ctx = egui::Context::default(); + ctx.set_os(os); + crate::fonts::install(&ctx, true); + let mut app = SoundApp::new(Engine::default(), None, crate::Services::default()); + assert!(app.dialogs.open_for_command(&app.engine, "track.new")); + for _ in 0..3 { + dialog_frame(&ctx, &mut app, vec![]); + } + (ctx, app) + } + + /// Runs one dialog frame with synthetic input events. + fn dialog_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + let mut output = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + show(app, ui.ctx()); + }); + output.textures_delta.clear(); + } + + /// Constructs one non-repeating key-press event for dialog tests. + fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } + } + + /// Returns every editable value from the active New Tracks row. + fn track_fields(app: &SoundApp) -> (u32, &str, &str, &str, &str) { + match app.dialogs.open.as_ref().unwrap() { + Dialog::NewTracks(tracks) => { + let row = tracks.active_row().unwrap(); + (row.count, &row.format, &row.kind, &row.timebase, &row.name) + } + other => panic!("unexpected dialog: {other:?}"), + } + } + + /// Opening selects the count, and bare arrows adjust it within its bounds. + #[test] + fn new_tracks_focus_selects_count_and_arrows_clamp_it() { + let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac); + dialog_frame(&ctx, &mut app, vec![egui::Event::Text("7".into())]); + assert_eq!(track_fields(&app).0, 7, "typing must replace the selected initial value"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]); + assert_eq!(track_fields(&app).0, 8); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]); + assert_eq!(track_fields(&app).0, 7); + if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open { + tracks.rows[0].count = 128; + } + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]); + assert_eq!(track_fields(&app).0, 128); + if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open { + tracks.rows[0].count = 1; + } + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]); + assert_eq!(track_fields(&app).0, 1); + } + + /// macOS command-arrow families edit format, type and timebase without changing count. + #[test] + fn new_tracks_mac_chords_change_settings_without_changing_count() { + for ctrl in [false, true] { + let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac); + let cmd = egui::Modifiers { command: true, mac_cmd: true, ctrl, ..Default::default() }; + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]); + assert_eq!(track_fields(&app), (1, "Stereo", "audio", "samples", "Audio")); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]); + assert_eq!(track_fields(&app).1, "Mono"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]); + assert_eq!(track_fields(&app).1, soundcraft_model::ChannelFormat::ALL.last().unwrap().label()); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]); + assert_eq!(track_fields(&app), (1, "Mono", "aux", "samples", "Aux")); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]); + assert_eq!(track_fields(&app).2, "audio"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]); + assert_eq!(track_fields(&app).2, "video"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]); + assert_eq!(track_fields(&app).4, "Audio"); + assert!(app.engine.session().tracks.is_empty()); + } + let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac); + let cmd_alt = egui::Modifiers { command: true, mac_cmd: true, alt: true, ..Default::default() }; + for (arrow, expected) in [(egui::Key::ArrowDown, "ticks"), (egui::Key::ArrowUp, "samples")] { + dialog_frame(&ctx, &mut app, vec![key(arrow, cmd_alt)]); + assert_eq!(track_fields(&app).3, expected); + assert_eq!(track_fields(&app).0, 1); + assert_eq!(track_fields(&app).2, "audio"); + } + } + + /// Type changes update default names while preserving names entered by the user. + #[test] + fn new_tracks_type_changes_preserve_custom_names() { + let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac); + if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open { + tracks.rows[0].name = "Dialogue cinema".into(); + } + let cmd = egui::Modifiers { command: true, mac_cmd: true, ..Default::default() }; + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]); + assert_eq!(track_fields(&app).4, "Dialogue cinema"); + let mut kind = "audio".to_string(); + let mut name = "Audio".to_string(); + set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Instrument); + assert_eq!(name, "Inst"); + name = "Foley".to_string(); + set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Aux); + assert_eq!(name, "Foley"); + } + + /// Enter creates, while Escape cancels even when both keys arrive in one frame. + #[test] + fn new_tracks_enter_creates_and_escape_cancels_on_each_platform() { + for os in [egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] { + let (ctx, mut app) = tracks_dialog(os); + dialog_frame(&ctx, &mut app, vec![egui::Event::Text("3".into())]); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + assert!(app.dialogs.open.is_none()); + assert_eq!(app.engine.session().tracks.len(), 3); + assert!(app.engine.session().markers.is_empty(), "Enter must not add a background marker"); + assert!(app.dialogs.open_for_command(&app.engine, "track.new")); + dialog_frame(&ctx, &mut app, vec![]); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]); + assert!(app.dialogs.open.is_none()); + assert_eq!(app.engine.session().tracks.len(), 3); + assert!(app.dialogs.open_for_command(&app.engine, "track.new")); + dialog_frame(&ctx, &mut app, vec![]); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]); + assert!(app.dialogs.open.is_none()); + assert_eq!(app.engine.session().tracks.len(), 3, "Escape must cancel a simultaneous Enter"); + } + } + + /// Row shortcuts change the form without submitting when Enter arrives in the same frame. + #[test] + fn new_tracks_row_shortcuts_take_precedence_over_simultaneous_enter() { + let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac); + let rows = egui::Modifiers { command: true, mac_cmd: true, shift: true, ..Default::default() }; + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]); + let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") }; + assert_eq!(tracks.rows.len(), 1, "the last row cannot be removed"); + assert!(app.engine.session().tracks.is_empty()); + + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]); + let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") }; + assert_eq!(tracks.rows.len(), 2); + assert!(app.engine.session().tracks.is_empty()); + + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]); + let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") }; + assert_eq!(tracks.rows.len(), 1); + assert!(app.engine.session().tracks.is_empty()); + } + + /// Escape prevents a simultaneous Enter from starting a background bounce. + #[test] + fn simultaneous_enter_and_escape_does_not_start_a_bounce() { + let ctx = egui::Context::default(); + crate::fonts::install(&ctx, true); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default()); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + for _ in 0..3 { + dialog_frame(&ctx, &mut app, vec![]); + } + dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]); + assert!(app.dialogs.open.is_none()); + assert!(app.bounce.is_none()); + } + + /// Escape prevents a simultaneous Save from scheduling the deferred session action. + #[test] + fn simultaneous_save_and_escape_does_not_continue_after_saving() { + let ctx = egui::Context::default(); + crate::fonts::install(&ctx, true); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default()); + let tracks = app.engine.session().tracks.len(); + app.dialogs.open = Some(Dialog::SaveChanges { then: ("session.close".into(), json!({})) }); + for _ in 0..3 { + dialog_frame(&ctx, &mut app, vec![]); + } + dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]); + assert!(app.dialogs.open.is_none()); + assert!(app.dialogs.after_save.is_none()); + assert_eq!(app.engine.session().tracks.len(), tracks); + } + + /// Windows and Linux chords cover every editable field and row action. + #[test] + fn new_tracks_windows_and_linux_chords_cover_every_field_and_row_action() { + for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] { + let (ctx, mut app) = tracks_dialog(os); + let ctrl = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl)]); + assert_eq!(track_fields(&app).1, "Stereo"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, ctrl)]); + assert_eq!(track_fields(&app).1, "Mono"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl)]); + assert_eq!(track_fields(&app).2, "aux"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl)]); + assert_eq!(track_fields(&app).2, "audio"); + + let ctrl_alt = egui::Modifiers { alt: true, ..ctrl }; + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl_alt)]); + assert_eq!(track_fields(&app).3, "ticks"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl_alt)]); + assert_eq!(track_fields(&app).3, "samples"); + + for alt in [false, true] { + let rows = egui::Modifiers { shift: true, alt, ..ctrl }; + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows)]); + let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") }; + assert_eq!(tracks.rows.len(), 2); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows)]); + let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") }; + assert_eq!(tracks.rows.len(), 1); + } + + let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open else { panic!("missing dialog") }; + tracks.rows[0].kind = "video".into(); + tracks.rows[0].timebase = "ticks".into(); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl), key(egui::Key::ArrowUp, ctrl_alt)]); + assert_eq!(track_fields(&app).1, "Mono", "Video has no format control"); + assert_eq!(track_fields(&app).3, "ticks", "Video has no timebase control"); + } + } /// A name that is not valid Unicode is not opened under a lossy rendering (another file): the /// status line names its bytes. diff --git a/crates/ui-egui/src/dialogs/audiosuite.rs b/crates/ui-egui/src/dialogs/audiosuite.rs index fbc650e6..759565a8 100644 --- a/crates/ui-egui/src/dialogs/audiosuite.rs +++ b/crates/ui-egui/src/dialogs/audiosuite.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, Value, json}; /// The speed ramp's choices in the AudioSuite window: param id, label, default, and (value, name) pairs. pub(super) const SPEED_RAMP_CHOICES: [(&str, &str, f32, &[(f32, &str)]); 3] = [ diff --git a/crates/ui-egui/src/dialogs/files.rs b/crates/ui-egui/src/dialogs/files.rs index a2d8d0a0..4c8e4281 100644 --- a/crates/ui-egui/src/dialogs/files.rs +++ b/crates/ui-egui/src/dialogs/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SoundApp, Value, json}; /// When `id` would close a session with unsaved changes, ask first; true when the question is /// open instead. A take in progress is stopped first, so it counts as a change. diff --git a/crates/ui-egui/src/dialogs/new_tracks.rs b/crates/ui-egui/src/dialogs/new_tracks.rs new file mode 100644 index 00000000..f7fc75e1 --- /dev/null +++ b/crates/ui-egui/src/dialogs/new_tracks.rs @@ -0,0 +1,613 @@ +//! One or more independent New Tracks rows, submitted as a single engine command. + +use egui::{Event, Key, TextStyle, Ui, vec2}; +use serde_json::{Value, json}; +use soundcraft_model::{ChannelFormat, TrackKind}; + +const MAX_TRACKS: usize = 128; +const NAME_WIDTH: f32 = 260.0; +const ROW_BUTTON_WIDTH: f32 = 22.0; + +#[derive(Debug, Clone)] +/// One independently configured row in the New Tracks dialog. +pub struct NewTrackRow { + key: u64, + pub count: u32, + pub format: String, + pub kind: String, + pub timebase: String, + pub name: String, +} + +impl Default for NewTrackRow { + /// Starts one mono audio row with the program defaults. + fn default() -> Self { + Self { key: 0, count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } + } +} + +impl NewTrackRow { + /// Builds one `track.new` row without values belonging to hidden controls. + fn params(&self) -> Value { + let mut params = json!({"count": self.count, "kind": self.kind, "name": self.name}); + if has_format(&self.kind) { + params["format"] = json!(self.format); + } + if has_timebase(&self.kind) { + params["timebase"] = json!(self.timebase); + } + params + } +} + +#[derive(Debug, Clone)] +/// State for the multi-row New Tracks dialog. +pub struct NewTracks { + pub rows: Vec, + active: usize, + next_key: u64, + focus_count: bool, +} + +impl Default for NewTracks { + /// Starts the dialog with one row whose count field receives focus. + fn default() -> Self { + Self { rows: vec![NewTrackRow::default()], active: 0, next_key: 1, focus_count: true } + } +} + +impl NewTracks { + /// Returns the row currently targeted by keyboard shortcuts. + pub fn active_row(&self) -> Option<&NewTrackRow> { + self.rows.get(self.active) + } + + /// Inserts a default row after `index` and moves focus to its count. + fn insert_after(&mut self, index: usize) { + if self.rows.len() >= MAX_TRACKS || self.rows.get(index).is_none() { + return; + } + let Some(next) = self.next_key.checked_add(1) else { return }; + self.rows.insert(index + 1, NewTrackRow { key: self.next_key, ..NewTrackRow::default() }); + self.next_key = next; + self.active = index + 1; + self.focus_count = true; + } + + /// Removes `index` while retaining at least one row and a valid active row. + fn remove(&mut self, index: usize) { + if self.rows.len() <= 1 || self.rows.get(index).is_none() { + return; + } + self.rows.remove(index); + if index == self.active { + self.active = index.min(self.rows.len().saturating_sub(1)); + self.focus_count = true; + } else if index < self.active { + self.active = self.active.saturating_sub(1); + } + } + + /// Handles the row, format, type and timebase shortcuts and reports a row action. + fn keys(&mut self, ctx: &egui::Context) -> bool { + let mac = ctx.os() == egui::os::OperatingSystem::Mac; + let mut row_action = false; + ctx.input_mut(|i| { + i.events.retain(|event| { + let Event::Key { key, pressed: true, modifiers: m, .. } = event else { return true }; + if !m.command { + return true; + } + if m.shift { + match key { + Key::ArrowDown => self.insert_after(self.active), + Key::ArrowUp => self.remove(self.active), + _ => return true, + } + row_action = true; + return false; + } + if m.alt && m.ctrl && mac { + return true; + } + let Some(row) = self.rows.get_mut(self.active) else { return true }; + let forward = matches!(key, Key::ArrowRight | Key::ArrowDown); + match key { + Key::ArrowLeft | Key::ArrowRight if !m.alt => { + if has_format(&row.kind) { + let index = ChannelFormat::ALL.iter().position(|f| f.label() == row.format).unwrap_or(0); + if let Some(next) = ChannelFormat::ALL.get(cycle_index(index, ChannelFormat::ALL.len(), forward)) { + row.format = next.label().to_string(); + } + } + } + Key::ArrowUp | Key::ArrowDown if m.alt => { + if has_timebase(&row.kind) { + row.timebase = if row.timebase == "ticks" { "samples" } else { "ticks" }.to_string(); + } + } + Key::ArrowUp | Key::ArrowDown => { + let index = TrackKind::ALL.iter().position(|k| k.id() == row.kind).unwrap_or(0); + if let Some(next) = TrackKind::ALL.get(cycle_index(index, TrackKind::ALL.len(), forward)) { + set_new_track_kind(&mut row.kind, &mut row.name, *next); + } + } + _ => return true, + } + false + }); + }); + row_action + } + + /// Draws every creation row and returns one atomic `track.new` request when submitted. + pub(crate) fn show(&mut self, ui: &mut Ui, enter: bool) -> Option { + let shortcut_row_action = self.keys(ui.ctx()); + let widths = RowWidths::new(ui); + ui.set_min_width(widths.total(ui)); + // The old row must not consume text on the first frame after adding a new one. + if self.focus_count && ui.is_enabled() && !ui.is_sizing_pass() { + ui.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + } + let mut insert = None; + let mut remove = None; + let len = self.rows.len(); + egui::ScrollArea::vertical().id_salt("new_tracks_rows").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| { + for (index, row) in self.rows.iter_mut().enumerate() { + // Explicit row IDs keep editing state with the row when rows above it are removed. + let id = egui::Id::unique(("new_tracks_row", row.key)); + let rect = egui::Rect::from_min_size(ui.available_rect_before_wrap().min, vec2(ui.available_width(), ui.spacing().interact_size.y)); + ui.scope_builder(egui::UiBuilder::new().scope_id(id).max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { + let label = ui.label("Create"); + let counter = ui + .scope_builder(egui::UiBuilder::new().scope_id(id.with("count")), |ui| { + ui.spacing_mut().interact_size.x = widths.count; + if self.focus_count && index == self.active && ui.is_enabled() && !ui.is_sizing_pass() { + ui.memory_mut(|m| m.request_focus(ui.next_auto_id())); + self.focus_count = false; + } + ui.add(egui::DragValue::new(&mut row.count).speed(1.0).range(1..=128)).labelled_by(label.id) + }) + .inner; + row.count = row.count.clamp(1, 128); + if counter.has_focus() || counter.clicked() { + self.active = index; + } + if counter.gained_focus() { + counter.scroll_to_me(Some(egui::Align::Center)); + } + ui.label("new"); + let format = if has_format(&row.kind) { + Some( + craft_ui::ComboBox::from_id_salt("nt_fmt") + .width(widths.format) + .selected_text(&row.format) + .show_ui(ui, |ui| { + for f in ChannelFormat::ALL { + ui.selectable_value(&mut row.format, f.label().to_string(), f.label()); + } + }) + .response + .accessible_name("Channel format"), + ) + } else { + ui.allocate_space(vec2(widths.format, ui.spacing().interact_size.y)); + None + }; + let kind = craft_ui::ComboBox::from_id_salt("nt_kind") + .width(widths.kind) + .selected_text(TrackKind::from_id(&row.kind).map_or("Audio Track", |k| k.label())) + .show_ui(ui, |ui| { + for k in TrackKind::ALL { + if ui.selectable_label(row.kind == k.id(), k.label()).clicked() { + set_new_track_kind(&mut row.kind, &mut row.name, k); + } + } + }) + .response + .accessible_name("Track kind"); + let timebase = if has_timebase(&row.kind) { + ui.label("in"); + Some( + craft_ui::ComboBox::from_id_salt("nt_tb") + .width(widths.timebase) + .selected_text(if row.timebase == "ticks" { "Ticks" } else { "Samples" }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut row.timebase, "samples".to_string(), "Samples"); + ui.selectable_value(&mut row.timebase, "ticks".to_string(), "Ticks"); + }) + .response + .accessible_name("Timebase"), + ) + } else { + ui.allocate_space(vec2(text_width(ui, "in"), ui.spacing().interact_size.y)); + ui.allocate_space(vec2(widths.timebase, ui.spacing().interact_size.y)); + None + }; + let label = ui.label("Name:"); + let name = crate::widgets::named_text_field( + ui.add(egui::TextEdit::singleline(&mut row.name).id(id.with("name")).desired_width(NAME_WIDTH)).labelled_by(label.id), + "Name:", + ); + if format.iter().chain(timebase.iter()).chain([&kind, &name]).any(|r| r.clicked() || r.has_focus()) { + self.active = index; + } + let size = vec2(ROW_BUTTON_WIDTH, ui.spacing().interact_size.y); + if ui.add_enabled(len > 1, egui::Button::new("−").min_size(size)).on_hover_text("Remove this row").clicked() { + remove = Some(index); + } + if ui.add_enabled(len < MAX_TRACKS, egui::Button::new("+").min_size(size)).on_hover_text("Add a row below").clicked() { + insert = Some(index); + } + }); + } + }); + let row_action = shortcut_row_action || remove.is_some() || insert.is_some(); + if let Some(index) = remove { + self.active = index; + self.remove(index); + ui.ctx().request_repaint(); + } else if let Some(index) = insert { + self.insert_after(index); + ui.ctx().request_repaint(); + } + let total = self.rows.iter().fold(0_u32, |total, row| total.saturating_add(row.count)); + let can_create = !self.rows.is_empty() && total <= MAX_TRACKS as u32; + if !can_create { + ui.colored_label(ui.visuals().error_fg_color, "Create up to 128 tracks at a time."); + } + // Enter on a focused row button changes that row only; it must not also submit the dialog. + if crate::dialogs::buttons_enabled(ui, "Create", enter && !row_action, can_create) && !row_action { + Some(json!({"rows": self.rows.iter().map(NewTrackRow::params).collect::>()})) + } else { + None + } + } +} + +/// Measure all choices with the current UI font, rather than the selected label. +struct RowWidths { + count: f32, + format: f32, + kind: f32, + timebase: f32, +} + +impl RowWidths { + /// Measures every possible choice so row columns stay fixed while values change. + fn new(ui: &Ui) -> Self { + Self { + count: (styled_text_width(ui, "128", &ui.style().drag_value_text_style) + 2.0 * ui.spacing().button_padding.x) + .ceil() + .max(ui.spacing().interact_size.x), + format: combo_width(ui, ChannelFormat::ALL.iter().map(|f| f.label())), + kind: combo_width(ui, TrackKind::ALL.iter().map(|k| k.label())), + timebase: combo_width(ui, ["Samples", "Ticks"].into_iter()), + } + } + + /// Returns the complete width required by one row, including labels and buttons. + fn total(&self, ui: &Ui) -> f32 { + let labels: f32 = ["Create", "new", "in", "Name:"].iter().map(|s| text_width(ui, s)).sum(); + labels + self.count + self.format + self.kind + self.timebase + NAME_WIDTH + 2.0 * ROW_BUTTON_WIDTH + 10.0 * ui.spacing().item_spacing.x + 8.0 + } +} + +/// Measures ordinary button text with the current theme and font scale. +fn text_width(ui: &Ui, text: &str) -> f32 { + styled_text_width(ui, text, &TextStyle::Button) +} + +/// Measures one string using the supplied egui text style. +fn styled_text_width(ui: &Ui, text: &str, style: &TextStyle) -> f32 { + let font = style.resolve(ui.style()); + ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), font, ui.visuals().text_color()).size().x) +} + +/// Computes a stable ComboBox width from all labels it may display. +fn combo_width<'a>(ui: &Ui, labels: impl Iterator) -> f32 { + let longest = labels.map(|s| text_width(ui, s)).fold(0.0, f32::max); + (longest + ui.spacing().icon_width + ui.spacing().icon_spacing + 2.0 * ui.spacing().button_padding.x + 2.0).ceil() +} + +/// Changes kind and updates the default name without replacing a custom name. +pub(crate) fn set_new_track_kind(kind: &mut String, name: &mut String, next: TrackKind) { + if TrackKind::from_id(kind).is_some_and(|old| name == old.default_name()) { + *name = next.default_name().to_string(); + } + *kind = next.id().to_string(); +} + +/// Reports whether a track kind exposes a channel-format control. +fn has_format(kind: &str) -> bool { + !matches!(TrackKind::from_id(kind), Some(TrackKind::Midi | TrackKind::Vca | TrackKind::Video)) +} + +/// Reports whether a track kind exposes a samples/ticks control. +fn has_timebase(kind: &str) -> bool { + !matches!(TrackKind::from_id(kind), Some(TrackKind::Video)) +} + +/// Moves one position through a circular choice list in either direction. +fn cycle_index(index: usize, len: usize, forward: bool) -> usize { + if forward { + if index.saturating_add(1) < len { index.saturating_add(1) } else { 0 } + } else { + index.checked_sub(1).unwrap_or(len.saturating_sub(1)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Services, SoundApp, dialogs::Dialog}; + use egui::{Modifiers, accesskit::Role}; + use egui_kittest::{Harness, kittest::Queryable}; + + /// Opens a settled New Tracks dialog in a macOS UI harness. + fn harness() -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + assert!(app.dialogs.open_for_command(&app.engine, "track.new")); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + h.run_steps(4); + h + } + + /// Borrows the dialog model from a test harness. + fn tracks<'a>(h: &'a Harness<'_, SoundApp>) -> &'a NewTracks { + let Some(Dialog::NewTracks(tracks)) = &h.state().dialogs.open else { panic!("missing New Tracks dialog") }; + tracks + } + + /// Mutably borrows the dialog model from a test harness. + fn tracks_mut<'a>(h: &'a mut Harness<'_, SoundApp>) -> &'a mut NewTracks { + let Some(Dialog::NewTracks(tracks)) = &mut h.state_mut().dialogs.open else { panic!("missing New Tracks dialog") }; + tracks + } + + /// Sends one keyboard chord and lets a resized dialog settle. + fn chord(h: &mut Harness, m: Modifiers, key: Key) { + h.key_press_modifiers(m, key); + // A centered window needs another frame to settle after its row count changes. + h.run_steps(4); + } + + /// Every selectable label retains its column's maximum measured width. + #[test] + fn every_choice_keeps_the_same_field_widths_and_name_position() { + let mut h = harness(); + let format = h.get_by_value("Mono").rect(); + let kind = h.get_by_value("Audio Track").rect(); + let timebase = h.get_by_value("Samples").rect(); + let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + assert!(name.width() >= NAME_WIDTH); + for f in ChannelFormat::ALL { + tracks_mut(&mut h).rows[0].format = f.label().to_string(); + h.run_steps(2); + assert_eq!(h.get_by_value(f.label()).rect().width(), format.width(), "{}", f.label()); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name); + } + for k in TrackKind::ALL { + tracks_mut(&mut h).rows[0].kind = k.id().to_string(); + h.run_steps(2); + assert_eq!(h.get_by_value(k.label()).rect().width(), kind.width(), "{}", k.label()); + let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width())); + } + tracks_mut(&mut h).rows[0].kind = "audio".to_string(); + tracks_mut(&mut h).rows[0].timebase = "ticks".to_string(); + tracks_mut(&mut h).rows[0].count = 128; + h.run_steps(2); + assert_eq!(h.get_by_value("Ticks").rect().width(), timebase.width()); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name); + h.ctx.global_style_mut(|s| s.drag_value_text_style = TextStyle::Heading); + tracks_mut(&mut h).rows[0].count = 1; + h.run_steps(4); + let larger_font_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + tracks_mut(&mut h).rows[0].count = 128; + h.run_steps(2); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), larger_font_name); + } + + /// Hidden controls retain their layout space for kinds that do not use them. + #[test] + fn inapplicable_fields_are_hidden_without_moving_the_other_columns() { + let mut h = harness(); + let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + let all_fields = h.get_all_by_role(Role::ComboBox).count(); + + for kind in [TrackKind::Midi, TrackKind::Vca] { + tracks_mut(&mut h).rows[0].kind = kind.id().to_string(); + h.run_steps(2); + assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 1, "{}", kind.label()); + let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width())); + } + + tracks_mut(&mut h).rows[0].kind = TrackKind::Video.id().to_string(); + h.run_steps(2); + assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 2); + let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width())); + } + + /// Submitted rows omit controls that are hidden for their selected track kind. + #[test] + fn inapplicable_fields_are_omitted_from_row_parameters() { + let row = NewTrackRow { + count: 2, + format: "Stereo".into(), + kind: TrackKind::Midi.id().into(), + timebase: "ticks".into(), + name: "MIDI".into(), + ..NewTrackRow::default() + }; + assert_eq!(row.params(), json!({"count": 2, "kind": "midi", "timebase": "ticks", "name": "MIDI"})); + + let row = NewTrackRow { kind: TrackKind::Vca.id().into(), name: "VCA".into(), ..row }; + assert_eq!(row.params(), json!({"count": 2, "kind": "vca", "timebase": "ticks", "name": "VCA"})); + + let row = NewTrackRow { kind: TrackKind::Video.id().into(), name: "Video".into(), ..row }; + assert_eq!(row.params(), json!({"count": 2, "kind": "video", "name": "Video"})); + } + + /// Both supported add/remove shortcut variants preserve independent row state. + #[test] + fn both_requested_row_chords_focus_the_new_count_and_preserve_other_rows() { + for alt in [false, true] { + let mut h = harness(); + let row_chord = Modifiers { command: true, mac_cmd: true, ctrl: true, shift: true, alt }; + tracks_mut(&mut h).rows[0].name = "Dialogue".into(); + chord(&mut h, row_chord, Key::ArrowDown); + assert_eq!(tracks(&h).rows.len(), 2); + assert_eq!(tracks(&h).active, 1); + h.event(Event::Text("7".into())); + h.run_steps(2); + assert_eq!(tracks(&h).rows[1].count, 7); + assert_eq!(tracks(&h).rows[0].count, 1); + assert_eq!(tracks(&h).rows[0].name, "Dialogue"); + chord(&mut h, Modifiers { command: true, mac_cmd: true, ..Default::default() }, Key::ArrowDown); + assert_eq!(tracks(&h).rows[1].kind, "aux"); + assert_eq!(tracks(&h).rows[0].kind, "audio"); + chord(&mut h, row_chord, Key::ArrowUp); + assert_eq!(tracks(&h).rows.len(), 1); + assert_eq!(tracks(&h).rows[0].name, "Dialogue"); + chord(&mut h, row_chord, Key::ArrowUp); + assert_eq!(tracks(&h).rows.len(), 1, "the last row cannot be removed"); + assert!(h.state().engine.session().tracks.is_empty()); + } + } + + /// Each row's add and remove buttons act at that row's position. + #[test] + fn plus_and_minus_buttons_operate_on_their_own_row() { + let mut h = harness(); + h.get_by_role_and_label(Role::Button, "+").click(); + h.run_steps(4); + let names: Vec<_> = h.get_all_by_role_and_label(Role::TextInput, "Name:").map(|n| n.rect()).collect(); + assert_eq!(names.len(), 2); + assert_eq!(names[0].min.x, names[1].min.x); + assert_eq!(names[0].width(), names[1].width()); + assert!(names[1].min.y - names[0].min.y <= 32.0, "rows must stay compact: {names:?}"); + tracks_mut(&mut h).rows[0].name = "Dialogue".into(); + tracks_mut(&mut h).rows[1].name = "Effects".into(); + h.get_all_by_role_and_label(Role::Button, "+").next().unwrap().click(); + h.run_steps(4); + assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::>(), ["Dialogue", "Audio", "Effects"]); + h.get_all_by_role_and_label(Role::Button, "−").nth(1).unwrap().click(); + h.run_steps(4); + assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::>(), ["Dialogue", "Effects"]); + } + + /// Enter on a focused row button changes the rows without also creating tracks. + #[test] + fn enter_on_row_buttons_does_not_submit_the_dialog() { + let mut h = harness(); + h.get_by_role_and_label(Role::Button, "+").focus(); + chord(&mut h, Modifiers::NONE, Key::Enter); + assert_eq!(tracks(&h).rows.len(), 2); + assert!(h.state().engine.session().tracks.is_empty()); + + h.get_all_by_role_and_label(Role::Button, "−").next().unwrap().focus(); + chord(&mut h, Modifiers::NONE, Key::Enter); + assert_eq!(tracks(&h).rows.len(), 1); + assert!(h.state().engine.session().tracks.is_empty()); + } + + /// Enter on the focused Cancel button closes the dialog without creating tracks. + #[test] + fn enter_on_cancel_does_not_submit_the_dialog() { + let mut h = harness(); + h.get_by_role_and_label(Role::Button, "Cancel").focus(); + chord(&mut h, Modifiers::NONE, Key::Enter); + assert!(h.state().dialogs.open.is_none()); + assert!(h.state().engine.session().tracks.is_empty()); + } + + /// Removing a preceding row does not transfer widget state to another row. + #[test] + fn removing_an_earlier_row_keeps_editing_state_with_the_surviving_row() { + let mut h = harness(); + chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown); + h.event(Event::Text("42".into())); + h.run_steps(2); + let focused = h.ctx.memory(|m| m.focused()); + let key = tracks(&h).rows[1].key; + tracks_mut(&mut h).remove(0); + h.run_steps(2); + assert_eq!(tracks(&h).rows[0].key, key); + assert_eq!(tracks(&h).rows[0].count, 42); + assert_eq!(h.ctx.memory(|m| m.focused()), focused); + chord(&mut h, Modifiers::NONE, Key::ArrowUp); + assert_eq!(tracks(&h).rows[0].count, 43); + } + + /// A clicked row receives later shortcuts without altering its custom name. + #[test] + fn clicking_a_different_row_targets_its_chords_and_keeps_its_custom_name() { + let mut h = harness(); + chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown); + tracks_mut(&mut h).rows[0].name = "Dialogue".into(); + h.get_all_by_role_and_label(Role::TextInput, "Name:").next().unwrap().click(); + h.run_steps(2); + let cmd = Modifiers { command: true, mac_cmd: true, ..Default::default() }; + chord(&mut h, cmd, Key::ArrowDown); + chord(&mut h, cmd, Key::ArrowRight); + chord(&mut h, Modifiers { alt: true, ..cmd }, Key::ArrowUp); + let rows = &tracks(&h).rows; + assert_eq!( + (&rows[0].kind, &rows[0].format, &rows[0].timebase, &rows[0].name), + (&"aux".to_string(), &"Stereo".to_string(), &"ticks".to_string(), &"Dialogue".to_string()) + ); + assert_eq!((&rows[1].kind, &rows[1].format, &rows[1].timebase), (&"audio".to_string(), &"Mono".to_string(), &"samples".to_string())); + assert_eq!((rows[0].count, rows[1].count), (1, 1)); + } + + /// Submitting several rows creates and undoes them as one engine action. + #[test] + fn create_submits_every_row_and_undo_removes_the_whole_creation() { + let mut h = harness(); + tracks_mut(&mut h).rows[0].count = 2; + chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown); + let row = &mut tracks_mut(&mut h).rows[1]; + row.kind = "aux".into(); + row.format = "Stereo".into(); + row.name = "Reverb".into(); + chord(&mut h, Modifiers::NONE, Key::Enter); + assert!(h.state().dialogs.open.is_none()); + assert_eq!( + h.state().engine.session().tracks.iter().map(|t| t.kind).collect::>(), + [TrackKind::Audio, TrackKind::Audio, TrackKind::Aux] + ); + assert_eq!(h.state().engine.session().tracks[2].name, "Reverb"); + assert!(h.state().engine.session().markers.is_empty()); + assert!(h.state_mut().engine.undo()); + assert!(h.state().engine.session().tracks.is_empty()); + assert!(!h.state().engine.can_undo()); + } + + /// The aggregate 128-track limit disables creation while leaving cancellation usable. + #[test] + fn too_many_tracks_disable_create_but_leave_cancel_available() { + let mut h = harness(); + tracks_mut(&mut h).rows[0].count = 128; + chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown); + chord(&mut h, Modifiers::NONE, Key::Enter); + assert!(h.state().dialogs.open.is_some()); + assert!(h.state().engine.session().tracks.is_empty()); + h.get_by_role_and_label(Role::Button, "Cancel").click(); + h.run_steps(2); + assert!(h.state().dialogs.open.is_none()); + } +} diff --git a/crates/ui-egui/src/dialogs/strip_silence.rs b/crates/ui-egui/src/dialogs/strip_silence.rs index a128436c..52f3da5b 100644 --- a/crates/ui-egui/src/dialogs/strip_silence.rs +++ b/crates/ui-egui/src/dialogs/strip_silence.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, ClipId, Dialog, SoundApp, StripNames, Tokens, TrackId, Value, json, vec2}; /// What Strip keeps on each track: the Strip Silence rectangles. pub type StripRects = Arc)>>; @@ -42,10 +42,7 @@ pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> Stri let values = match &app.dialogs.open { Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) => [*threshold, *min_ms, *pre_ms, *post_ms], Some(Dialog::StripSilenceRename { strip, .. }) => *strip, - _ => { - app.dialogs.strip_rectangles = StripRectangles::default(); - return Arc::default(); - } + _ => return Arc::default(), }; let key: StripKey = (app.engine.revision, values.map(f64::to_bits)); let r = &mut app.dialogs.strip_rectangles; diff --git a/crates/ui-egui/src/edit_window/automation.rs b/crates/ui-egui/src/edit_window/automation.rs index 61d2b3a5..d3127982 100644 --- a/crates/ui-egui/src/edit_window/automation.rs +++ b/crates/ui-egui/src/edit_window/automation.rs @@ -1,5 +1,5 @@ use super::layout::{sample_at, x_of}; -use super::*; +use super::{Align2, AutoParam, Color32, Pos2, Rect, Samples, Session, Shape, Stroke, Tokens, Track, TrackKind, pos2, regular}; pub(super) fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &AutoParam, tl: Rect, lane: Rect) { let t = Tokens::current(); diff --git a/crates/ui-egui/src/edit_window/clips.rs b/crates/ui-egui/src/edit_window/clips.rs index 6445c95c..5887132d 100644 --- a/crates/ui-egui/src/edit_window/clips.rs +++ b/crates/ui-egui/src/edit_window/clips.rs @@ -1,7 +1,10 @@ use super::drag::fade_drag_preview; use super::input::{clip_gain_icon, near_fade_midpoint}; use super::layout::{sample_at, x_of}; -use super::*; +use super::{ + Align2, AutoParam, Clip, ClipContent, Color32, CornerRadius, Gesture, GridValue, NoteValue, Pos2, Range, Rect, Session, Shape, SoundApp, Stroke, + StrokeKind, TimeFormat, Tokens, Tool, Track, bold, clip_colors, format_position, pos2, regular, vec2, +}; /// The outline of the Strip Silence rectangles. pub fn strip_silence_stroke() -> Stroke { @@ -138,18 +141,39 @@ fn grid_veil(line: Color32, bg: Color32) -> Color32 { } } -/// `sorted`: whether `track.clips()` is sorted by start (worked out once per track and frame). -pub(super) fn draw_clip( - app: &SoundApp, - painter: &egui::Painter, - s: &Session, - track: &Track, - sorted: bool, - clip: &Clip, - tl: Rect, - lane: Rect, +/// What `draw_clip` draws into: the app, painter and session, and the track lane the clips sit on. +#[derive(Clone, Copy)] +pub(super) struct ClipLane<'a> { + pub(super) app: &'a SoundApp, + pub(super) painter: &'a egui::Painter, + pub(super) s: &'a Session, + pub(super) track: &'a Track, + /// Whether `track.clips()` is sorted by start (worked out once per track and frame). + pub(super) sorted: bool, + pub(super) tl: Rect, + pub(super) lane: Rect, +} + +/// Where and in what colours one clip is drawn, shared by the parts of `draw_clip`. +#[derive(Clone, Copy)] +struct ClipGeom<'a> { + r: Rect, + body_r: Rect, + vis: Rect, + x0: f32, + x1: f32, + name_h: f32, + body: Color32, + wave: Color32, + selected: bool, dim: bool, -) { + fade_in: soundcraft_model::Fade, + fade_out: soundcraft_model::Fade, + prev: Option<&'a Clip>, +} + +pub(super) fn draw_clip(cl: &ClipLane<'_>, clip: &Clip, dim: bool) { + let ClipLane { app, painter, s, track, tl, lane, .. } = *cl; let x0 = x_of(s, tl, clip.start); let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0); let r = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0)); @@ -186,6 +210,19 @@ pub(super) fn draw_clip( let vis = body_r.intersect(lane); // The waveform and fade curve share the same temporary envelope every frame. let (fade_in, fade_out) = clip_preview_fades(clip, app.gesture.as_ref().filter(|_| !dim)); + let prev = crossfade_partner(track, clip); + let g = ClipGeom { r, body_r, vis, x0, x1, name_h, body, wave, selected, dim, fade_in, fade_out, prev }; + draw_clip_content(cl, clip, &g); + draw_crossfade_tail(cl, &g); + draw_gain_line(cl, clip, &g); + draw_fades(cl, clip, &g); + draw_clip_overlays(cl, clip, &g); +} + +/// The clip body: waveform, MIDI notes or the offline notice. +fn draw_clip_content(cl: &ClipLane<'_>, clip: &Clip, g: &ClipGeom<'_>) { + let ClipLane { painter, s, track, tl, .. } = *cl; + let ClipGeom { body_r, vis, body, wave, selected, fade_in, fade_out, .. } = *g; match &clip.content { _ if track.view == "blocks" => {} ClipContent::Audio { source, .. } => { @@ -247,7 +284,12 @@ pub(super) fn draw_clip( } } } - let prev = crossfade_partner(track, clip); +} + +/// The waveform of the clip this one crossfades with, drawn over the overlap. +fn draw_crossfade_tail(cl: &ClipLane<'_>, g: &ClipGeom<'_>) { + let ClipLane { painter, s, track, tl, .. } = *cl; + let ClipGeom { body_r, vis, x0, x1, dim, prev, .. } = *g; if s.edit.flag("waveform.overlapped_crossfades") && track.view != "blocks" && let Some(prev) = prev @@ -262,6 +304,12 @@ pub(super) fn draw_clip( }; crate::waveform::draw(crate::waveform::Canvas { painter: &painter.with_clip_rect(xf), s, tl }, prev, audio, track.channels(), body_r, tail); } +} + +/// The clip gain line and its icon. +fn draw_gain_line(cl: &ClipLane<'_>, clip: &Clip, g: &ClipGeom<'_>) { + let ClipLane { painter, s, track, tl, lane, .. } = *cl; + let ClipGeom { body_r, x0, x1, dim, .. } = *g; // Clip gain line. let show_gain = s.edit.flag("view.clip.gain_line") && (!clip.gain_env.is_empty() || clip.gain_db.abs() > 0.01 || track.view == "clip_gain"); if show_gain && body_r.height() > 12.0 { @@ -286,6 +334,12 @@ pub(super) fn draw_clip( painter.rect_filled(Rect::from_center_size(pos2(icon.center().x, icon.center().y + 2.0), vec2(icon.width(), 3.0)), 1.0, col); painter.text(pos2(icon.max.x + 3.0, icon.center().y), Align2::LEFT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col); } +} + +/// The fade curves, their midpoint handles and the crossfade partner's fade-out. +fn draw_fades(cl: &ClipLane<'_>, clip: &Clip, g: &ClipGeom<'_>) { + let ClipLane { app, painter, s, track, tl, .. } = *cl; + let ClipGeom { body_r, vis, x0, x1, body, dim, fade_in, fade_out, prev, .. } = *g; let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90); for (side, fade) in [(true, fade_in), (false, fade_out)] { if fade.len <= 0 { @@ -309,6 +363,12 @@ pub(super) fn draw_clip( let pts = fade_curve_points(c.fade_out, false, a, b, body_r, painter.ctx().pixels_per_point()); painter.with_clip_rect(vis).add(Shape::line(pts, Stroke::new(1.2, fade_col))); } +} + +/// Optional clip overlays (View › Clip). +fn draw_clip_overlays(cl: &ClipLane<'_>, clip: &Clip, g: &ClipGeom<'_>) { + let ClipLane { painter, s, track, sorted, tl, lane, .. } = *cl; + let ClipGeom { r, name_h, .. } = *g; // Optional clip overlays (View › Clip). let ov = Color32::from_rgb(16, 16, 16); if s.edit.flag("view.clip.rating") && clip.rating > 0 && name_h > 0.0 { @@ -323,23 +383,7 @@ pub(super) fn draw_clip( )); painter.line_segment([pos2(x, r.min.y + name_h), pos2(x, r.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 90))); } - let time_text = if s.edit.flag("view.clip.time.current") { - Some(s.format_position(clip.start, s.edit.main_counter)) - } else if s.edit.flag("view.clip.time.original") || s.edit.flag("view.clip.time.user") { - clip.source().and_then(|src| s.source(src)).map(|src| { - format_position( - i64::try_from(src.time_reference).unwrap_or(0).saturating_add(clip.source_offset()).saturating_sub(s.timecode_start), - TimeFormat::Timecode, - s.sample_rate, - &s.tempo, - s.frame_rate, - 0, - 0, - ) - }) - } else { - None - }; + let time_text = clip_time_text(s, clip); if let Some(tt) = time_text && r.height() > 30.0 { @@ -369,7 +413,27 @@ pub(super) fn draw_clip( if clip.edit_locked || clip.time_locked { painter.text(pos2(r.max.x - 10.0, r.min.y + 7.0), Align2::CENTER_CENTER, "🔒", regular(9.0), Color32::WHITE); } - let _ = app; +} + +/// The clip's start (or original) time label for the overlays, when one is shown. +fn clip_time_text(s: &Session, clip: &Clip) -> Option { + if s.edit.flag("view.clip.time.current") { + Some(s.format_position(clip.start, s.edit.main_counter)) + } else if s.edit.flag("view.clip.time.original") || s.edit.flag("view.clip.time.user") { + clip.source().and_then(|src| s.source(src)).map(|src| { + format_position( + i64::try_from(src.time_reference).unwrap_or(0).saturating_add(clip.source_offset()).saturating_sub(s.timecode_start), + TimeFormat::Timecode, + s.sample_rate, + &s.tempo, + s.frame_rate, + 0, + 0, + ) + }) + } else { + None + } } /// The earlier clip of a crossfade pair: the one before `clip` in its playlist, overlapping its start. diff --git a/crates/ui-egui/src/edit_window/drag.rs b/crates/ui-egui/src/edit_window/drag.rs index 09c6b326..71388d24 100644 --- a/crates/ui-egui/src/edit_window/drag.rs +++ b/crates/ui-egui/src/edit_window/drag.rs @@ -1,7 +1,9 @@ use super::automation::{auto_value, automation_id, lane_range, plugin_range}; use super::input::{new_midi_clip, snap}; use super::layout::{lane_rect, rect, sample_at}; -use super::*; +use super::{ + Arc, AutoParam, Clip, ClipId, Dialog, EditMode, Gesture, HashSet, Pos2, Range, Rect, Samples, Session, SoundApp, Spot, TrackId, json, ops, pos2, +}; /// The same bounded range drives the preview and the committed fade. pub(super) fn fade_drag_range(clip: &Clip, fade_in: bool, to: Samples) -> Option { @@ -32,7 +34,7 @@ pub(super) fn fade_curve_drag_value(fade: soundcraft_model::Fade, anchor: (f32, /// Runs before anything is painted, against the last frame's layout. pub(super) fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { - if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. })) { + if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) { return; } let (s, tl) = (app.engine.session_arc(), rect(app.edit_layout.timeline)); diff --git a/crates/ui-egui/src/edit_window/header.rs b/crates/ui-egui/src/edit_window/header.rs index df0d1905..f8a002a2 100644 --- a/crates/ui-egui/src/edit_window/header.rs +++ b/crates/ui-egui/src/edit_window/header.rs @@ -1,12 +1,34 @@ use super::tracks::{click_track, folder_depth, height_targets, in_edit_window, track_context_menu, view_label, view_menu}; -use super::*; +use super::{ + Align2, AutoParam, Color32, Pos2, Rect, Sense, Shape, SoundApp, Stroke, Tokens, Track, TrackHeight, TrackId, TrackKind, Ui, bold, groove, json, + level_pos, meter_pos, pos2, raised_key, regular, rgb, shade, vec2, +}; /// Draw one track header: resize edge, selection and context menu, folder/options control, automation-lane toggle, /// name and the remaining track controls. pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool, head_bg: Color32) { - let t = Tokens::current(); let id = track.id; - // Drag the bottom edge to resize (snaps to the height presets). + // Click on header background selects the track. + let bg = ui.interact(head, ui.scope_id().with(("hdr", id.0)), Sense::click()); + if bg.clicked() { + click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); + } + bg.context_menu(|ui| track_context_menu(app, ui, id)); + resize_edge(app, ui, track, head); + strip_buttons(app, ui, track, head); + name_plate(app, ui, track, head, selected); + playlist_menu(app, ui, track, head, head_bg); + toggle_keys(app, ui, track, head); + view_selector(app, ui, track, head); + voice_row(app, ui, track, head); + field_row(app, ui, track, head, head_bg); + let md = app.meters.get(&id).copied().unwrap_or_default(); + header_meter(ui, Rect::from_min_max(pos2(head.max.x - 8.0, head.min.y + 1.0), pos2(head.max.x, head.max.y - 1.5)), &md, track.channels() >= 2); +} + +/// Drag the bottom edge of the header to resize the track (snaps to the height presets). +fn resize_edge(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { + let id = track.id; let edge = Rect::from_min_max(pos2(head.min.x, head.max.y - 4.0), pos2(head.max.x, head.max.y + 2.0)); let eresp = ui.interact(edge, ui.scope_id().with(("hedge", id.0)), Sense::drag()); if eresp.hovered() || eresp.dragged() { @@ -34,17 +56,15 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: ui.ctx().request_repaint(); } } +} + +/// The strip's round options/folder button and the automation-lane toggle. +fn strip_buttons(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { + let id = track.id; let h = head.height(); - // Click on header background selects the track. - let bg = ui.interact(head, ui.scope_id().with(("hdr", id.0)), Sense::click()); - if bg.clicked() { - click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); - } - bg.context_menu(|ui| track_context_menu(app, ui, id)); let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h)); let strip = rgb(track.color); let ink = shade(strip, -0.8); - // The strip's round button opens a folder, or any other track's options. let round = at(2.0, 2.0, 10.0, 10.0); let rresp = ui.interact(round, ui.scope_id().with(("options", id.0)), Sense::click()); ui.painter().circle_filled(round.center(), 5.0, shade(strip, if rresp.hovered() { 0.6 } else { 0.45 })); @@ -76,7 +96,13 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: let _ = app.run("track.view", json!({"track": id.0, "view": view})); } } - // Name, highlighted when the track is selected, and the playlist menu beside it. +} + +/// The track name, highlighted when selected, with click, rename and drag-to-reorder. +fn name_plate(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool) { + let t = Tokens::current(); + let id = track.id; + let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h)); let indent = folder_depth(app.engine.session(), track) as f32 * 10.0; let name_r = at(16.0 + indent, 1.5, 97.0 - indent, 15.0); if selected { @@ -128,6 +154,13 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: let _ = app.run("track.move", json!({"track": id.0, "to": to})); } } +} + +/// The playlist menu button beside the name. +fn playlist_menu(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, head_bg: Color32) { + let t = Tokens::current(); + let id = track.id; + let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h)); let pl_r = at(114.0, 1.0, 11.0, 14.5); let presp = ui.interact(pl_r, ui.scope_id().with(("pl", id.0)), Sense::click()); ui.painter().rect_filled(pl_r, 1.5, shade(head_bg, if presp.hovered() { -0.2 } else { -0.35 })); @@ -149,6 +182,13 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: } } }); +} + +/// Record, input, solo and mute keys. +fn toggle_keys(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { + let id = track.id; + let h = head.height(); + let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h)); let m = &track.mixer; if h >= 37.0 { let (records, mixes) = (track.kind.has_playlist(), track.kind != TrackKind::Master); @@ -165,6 +205,14 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: } } } +} + +/// The track view selector. +fn view_selector(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { + let t = Tokens::current(); + let id = track.id; + let h = head.height(); + let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h)); if h >= 58.0 { let vresp = header_selector( ui, @@ -177,6 +225,15 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: ); egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track)); } +} + +/// Freeze, voice and automation mode. +fn voice_row(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { + let t = Tokens::current(); + let id = track.id; + let h = head.height(); + let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h)); + let m = &track.mixer; if h >= 76.0 { if matches!(track.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Aux) { let r = at(17.0, 57.0, 13.0, 17.0); @@ -220,6 +277,14 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: } }); } +} + +/// Timebase key and the instrument or elastic field. +fn field_row(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, head_bg: Color32) { + let t = Tokens::current(); + let id = track.id; + let h = head.height(); + let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h)); if h >= 94.0 { if track.kind.has_playlist() { let r = at(17.0, 75.0, 13.0, 17.0); @@ -277,8 +342,6 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: } } } - let md = app.meters.get(&id).copied().unwrap_or_default(); - header_meter(ui, Rect::from_min_max(pos2(head.max.x - 8.0, head.min.y + 1.0), pos2(head.max.x, head.max.y - 1.5)), &md, track.channels() >= 2); } /// A small menu arrow, pointing down, or right for a closed folder. diff --git a/crates/ui-egui/src/edit_window/input.rs b/crates/ui-egui/src/edit_window/input.rs index 9875a1e6..8529db9a 100644 --- a/crates/ui-egui/src/edit_window/input.rs +++ b/crates/ui-egui/src/edit_window/input.rs @@ -2,7 +2,10 @@ use super::automation::{automation_id, breakpoint_at, plugin_range, shown_automa use super::clips::{clip_name_height, fade_control_position}; use super::drag::{drag_snap, fade_curve_drag_value, fade_drag_range, spot_or_run}; use super::layout::{sample_at, x_of}; -use super::*; +use super::{ + Align2, AutoParam, Clip, ClipContent, ClipId, Color32, Dialog, EditMode, Gesture, GridValue, NoteValue, Pos2, Range, Rect, Samples, Sense, + Session, SoundApp, Spot, Stroke, StrokeKind, Tokens, Tool, Track, TrackId, Ui, json, ops, pos2, regular, vec2, +}; // ---- interaction ----------------------------------------------------------------------------- @@ -86,9 +89,40 @@ fn clip_gain_popup(ctx: &egui::Context, fader_bottom: Pos2, pos: f32) { p.text(pos2(panel.center().x, panel.min.y + 12.0), Align2::CENTER_CENTER, format!("{db:+.1}"), regular(10.5), t.counter_text); } -/// Handle one track lane: clip context menus, drag-and-drop sources, sampler drops, and the active tool's selection, -/// trim, fade, automation and clip editing. -pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { +/// What a lane interaction knows before it hit-tests: the lane, the session and the press point. +struct LaneInput<'a> { + track: &'a Track, + lane: Rect, + tl: Rect, + s: &'a Session, + tool: Tool, + origin: Pos2, + at_origin: Samples, + auto_view: Option, +} + +/// What is under the pointer: a breakpoint, fade handles and the clip body. +struct LaneHits<'a> { + point_hit: Option<(AutoParam, Samples)>, + fade_handle: Option<(&'a Clip, bool)>, + curve_handle: Option<(&'a Clip, bool)>, + hit: Option, +} + +/// Everything the lane's gesture, click and cursor handling reads. +struct LaneCtx<'a> { + inp: LaneInput<'a>, + hits: LaneHits<'a>, + mods: egui::Modifiers, + p: Pos2, + at: Samples, + eff: Tool, + fade_corner: Option, + selector_press: bool, +} + +/// The lane's response, with its clip context menu and the drops it accepts. +fn lane_menu_and_drops(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) -> egui::Response { let id = ui.scope_id().with(("lane", track.id.0)); let resp = ui.interact(lane, id, Sense::click_and_drag()); // The clip menu is attached before the early return below: once the menu is open the pointer @@ -121,16 +155,15 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l if track.kind == soundcraft_model::TrackKind::Instrument { crate::sampler_window::drop_target(app, ui.ctx(), lane, track.id); } - let s = app.engine.session_arc(); - let tool = s.edit.tool; - let mods = ui.input(|i| i.modifiers); - let Some(p) = resp.interact_pointer_pos().or_else(|| resp.hover_pos()) else { return }; - let at = sample_at(&s, tl, p.x).max(0); - let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(p) } else { p }; - let at_origin = sample_at(&s, tl, origin.x).max(0); - let auto_view = AutoParam::parse(&track.view); + resp +} + +/// Hit-test the press point against breakpoints, fade handles and clip bodies. +fn hit_test<'a>(inp: &LaneInput<'a>) -> LaneHits<'a> { + let (track, lane, tl, s, tool, origin, at_origin) = (inp.track, inp.lane, inp.tl, inp.s, inp.tool, inp.origin, inp.at_origin); + let auto_view = &inp.auto_view; let point_hit = if matches!(tool, Tool::Smart | Tool::Grabber | Tool::Pencil) { - shown_automation(track).and_then(|param| breakpoint_at(&s, tl, lane, track, ¶m, origin).map(|at| (param, at))) + shown_automation(track).and_then(|param| breakpoint_at(s, tl, lane, track, ¶m, origin).map(|at| (param, at))) } else { None }; @@ -143,34 +176,40 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l .iter() .rev() .filter(|c| body_hit.is_none_or(|front| front.id == c.id || front.start == c.end())) - .find_map(|c| fade_handle_at(&s, tl, lane, c, origin).map(|side| (c, side))) + .find_map(|c| fade_handle_at(s, tl, lane, c, origin).map(|side| (c, side))) } else { None }; let curve_handle = if tool == Tool::Smart && auto_view.is_none() && fade_handle.is_none() { - body_hit.and_then(|c| fade_curve_handle_at(&s, tl, lane, c, origin).map(|side| (c, side))) + body_hit.and_then(|c| fade_curve_handle_at(s, tl, lane, c, origin).map(|side| (c, side))) } else { None }; let hit = fade_handle.or(curve_handle).map(|(c, _)| c).or(body_hit).cloned(); - let upper = origin.y < lane.center().y; - let near_edge = |c: &Clip| -> Option { - let xs = x_of(&s, tl, c.start); - let xe = x_of(&s, tl, c.end()); - if (origin.x - xs).abs() < 6.0 { - Some(true) - } else if (origin.x - xe).abs() < 6.0 { - Some(false) - } else { - None - } - }; - // Effective tool under the Smart tool; on a breakpoint every tool that edits one grabs it. - let eff = match tool { - _ if point_hit.is_some() => Tool::Grabber, + LaneHits { point_hit, fade_handle, curve_handle, hit } +} + +/// Which edge of `c` the press point is within a few points of: `Some(true)` the start, `Some(false)` the end. +fn near_edge(inp: &LaneInput<'_>, c: &Clip) -> Option { + let xs = x_of(inp.s, inp.tl, c.start); + let xe = x_of(inp.s, inp.tl, c.end()); + if (inp.origin.x - xs).abs() < 6.0 { + Some(true) + } else if (inp.origin.x - xe).abs() < 6.0 { + Some(false) + } else { + None + } +} + +/// Effective tool under the Smart tool; on a breakpoint every tool that edits one grabs it. +fn effective_tool(inp: &LaneInput<'_>, hits: &LaneHits<'_>) -> Tool { + let upper = inp.origin.y < inp.lane.center().y; + match inp.tool { + _ if hits.point_hit.is_some() => Tool::Grabber, Tool::Smart => { - if let Some(c) = &hit { - if near_edge(c).is_some() { + if let Some(c) = &hits.hit { + if near_edge(inp, c).is_some() { Tool::Trim } else if upper { Tool::Selector @@ -182,23 +221,18 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l } } other => other, - }; - if resp.hovered() { - let icon = match eff { - Tool::Trim => egui::CursorIcon::ResizeHorizontal, - Tool::Grabber => egui::CursorIcon::Grab, - Tool::Selector => egui::CursorIcon::Text, - Tool::Zoom => egui::CursorIcon::ZoomIn, - Tool::Pencil => egui::CursorIcon::Crosshair, - _ => egui::CursorIcon::Default, - }; - ui.ctx().set_cursor_icon(icon); } - let fade_corner = if tool == Tool::Smart && point_hit.is_none() { +} + +/// The fade corner (`true` for the fade-in) the Smart tool is pressing, if any. +fn fade_corner_at(inp: &LaneInput<'_>, hits: &LaneHits<'_>) -> Option { + let (s, tl, lane, origin) = (inp.s, inp.tl, inp.lane, inp.origin); + let (tool, point_hit, fade_handle, hit) = (inp.tool, &hits.point_hit, hits.fade_handle, &hits.hit); + if tool == Tool::Smart && point_hit.is_none() { fade_handle.map(|(_, side)| side).or_else(|| { hit.as_ref().and_then(|c| { - let xs = x_of(&s, tl, c.start); - let xe = x_of(&s, tl, c.end()); + let xs = x_of(s, tl, c.start); + let xe = x_of(s, tl, c.end()); let top = origin.y < lane.min.y + lane.height() * 0.28; if !top { None @@ -213,25 +247,15 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l }) } else { None - }; - if fade_corner.is_some() && resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); } - if curve_handle.is_some() && resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); - } - let gain_icon_at = |q: Pos2| { - let c = clip_at(track, sample_at(&s, tl, q.x).max(0)).filter(|_| auto_view.is_none())?; - clip_gain_icon(&s, tl, lane, c).filter(|r| r.contains(q)).map(|_| c.clone()) - }; - if resp.hovered() && gain_icon_at(p).is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); - } - let selector_press = eff == Tool::Selector && fade_corner.is_none() && curve_handle.is_none() && gain_icon_at(origin).is_none(); - if selector_press && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) { - place_insertion(app, ui, &s, track.id, at, mods.shift); - } - let gain_drag = if resp.drag_started() { gain_icon_at(origin) } else { None }; +} + +/// Start the gesture a press begins: a breakpoint, clip gain, a fade or the tool's own. +fn start_gesture(app: &mut SoundApp, ui: &mut Ui, c: &LaneCtx<'_>, resp: &egui::Response, gain_drag: Option) { + let LaneCtx { inp, hits, mods, p, at, eff, fade_corner, .. } = c; + let (track, lane, s, origin, at_origin, auto_view) = (inp.track, inp.lane, inp.s, inp.origin, inp.at_origin, &inp.auto_view); + let (point_hit, curve_handle, hit) = (&hits.point_hit, hits.curve_handle, &hits.hit); + let (mods, p, at, eff, fade_corner) = (*mods, *p, *at, *eff, *fade_corner); if resp.drag_started() && let Some((param, at)) = &point_hit { @@ -252,7 +276,7 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l { let fade = if fade_in { c.fade_in } else { c.fade_out }; let anchor = (origin.y, fade.gain(0.5)); - let height = (lane.height() - 2.0 - clip_name_height(&s, lane)).max(1.0); + let height = (lane.height() - 2.0 - clip_name_height(s, lane)).max(1.0); app.gesture = Some(Gesture::FadeCurve { clip: c.id, fade_in, curvature: fade_curve_drag_value(fade, anchor, height, p.y), anchor, height }); ui.ctx().request_repaint(); } else if resp.drag_started() @@ -267,12 +291,12 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l ui.ctx().request_repaint(); } else if resp.drag_started() { app.gesture = match eff { - Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at_origin), tracks: vec![track.id] }), + Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(s, at_origin), tracks: vec![track.id] }), Tool::Grabber => hit.as_ref().map(|c| { let clips = if s.edit.selected_clips.contains(&c.id) { s.edit.selected_clips.clone() } else { vec![c.id] }; Gesture::MoveClips { clips, grab_at: at_origin, delta: 0, from_track: track.id, to_track: track.id } }), - Tool::Trim => hit.as_ref().map(|c| match near_edge(c) { + Tool::Trim => hit.as_ref().map(|c| match near_edge(inp, c) { Some(false) => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, _ if at_origin - c.start < c.end() - at_origin => Gesture::TrimStart { clip: c.id, track: track.id, to: c.start }, _ => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, @@ -285,6 +309,11 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l Tool::Zoom => Some(Gesture::ZoomBox { start: origin.x }), }; } +} + +/// While a clip-gain gesture is dragged, the fader follows the pointer. +fn drag_clip_gain(app: &mut SoundApp, ui: &mut Ui, c: &LaneCtx<'_>, resp: &egui::Response) { + let (track, s, mods, p) = (c.inp.track, c.inp.s, c.mods, c.p); if let Some(Gesture::ClipGain { clip, track: t, pos, anchor, fine, fader_bottom }) = &mut app.gesture && *t == track.id && resp.dragged() @@ -303,6 +332,13 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); clip_gain_popup(ui.ctx(), *fader_bottom, *pos); } +} + +/// Click handling for the lane: breakpoints, clip selection, spot dialogs, zoom and pencil points. +fn lane_click(app: &mut SoundApp, ui: &mut Ui, c: &LaneCtx<'_>, resp: &egui::Response) { + let (track, lane, s, auto_view) = (c.inp.track, c.inp.lane, c.inp.s, &c.inp.auto_view); + let (point_hit, curve_handle, hit) = (&c.hits.point_hit, c.hits.curve_handle, &c.hits.hit); + let (mods, p, at, eff, selector_press) = (c.mods, c.p, c.at, c.eff, c.selector_press); if resp.clicked() && let Some((param, at)) = &point_hit { @@ -319,14 +355,14 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l ids.push(c.id.0); let _ = app.run("edit.select", json!({"clips": ids})); if s.edit.edit_mode == EditMode::Spot { - app.dialogs.open = Spot::clips(&s, vec![c.id], None, 0).map(Dialog::Spot); + app.dialogs.open = Spot::clips(s, vec![c.id], None, 0).map(Dialog::Spot); } } } Tool::Trim if s.edit.edit_mode == EditMode::Spot && hit.is_some() => { if let Some(c) = &hit { let start = at - c.start < c.end() - at; - app.dialogs.open = Spot::trim(&s, c.id, start, if start { c.start } else { c.end() }).map(Dialog::Spot); + app.dialogs.open = Spot::trim(s, c.id, start, if start { c.start } else { c.end() }).map(Dialog::Spot); } } Tool::Zoom => { @@ -352,11 +388,11 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l Some(c) => { let _ = app.run("edit.select", json!({"clips": [c.id.0]})); } - None => new_midi_clip(app, &s, track.id, at, at), + None => new_midi_clip(app, s, track.id, at, at), } } } - _ if !selector_press => place_insertion(app, ui, &s, track.id, at, mods.shift), + _ if !selector_press => place_insertion(app, ui, s, track.id, at, mods.shift), _ => {} } } @@ -373,6 +409,58 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l } } +/// Handle one track lane: clip context menus, drag-and-drop sources, sampler drops, and the active tool's selection, +/// trim, fade, automation and clip editing. +pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { + let resp = lane_menu_and_drops(app, ui, track, lane, tl); + let s = app.engine.session_arc(); + let tool = s.edit.tool; + let mods = ui.input(|i| i.modifiers); + let Some(p) = resp.interact_pointer_pos().or_else(|| resp.hover_pos()) else { return }; + let at = sample_at(&s, tl, p.x).max(0); + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(p) } else { p }; + let at_origin = sample_at(&s, tl, origin.x).max(0); + let auto_view = AutoParam::parse(&track.view); + let inp = LaneInput { track, lane, tl, s: &s, tool, origin, at_origin, auto_view }; + let hits = hit_test(&inp); + let eff = effective_tool(&inp, &hits); + if resp.hovered() { + let icon = match eff { + Tool::Trim => egui::CursorIcon::ResizeHorizontal, + Tool::Grabber => egui::CursorIcon::Grab, + Tool::Selector => egui::CursorIcon::Text, + Tool::Zoom => egui::CursorIcon::ZoomIn, + Tool::Pencil => egui::CursorIcon::Crosshair, + _ => egui::CursorIcon::Default, + }; + ui.ctx().set_cursor_icon(icon); + } + let fade_corner = fade_corner_at(&inp, &hits); + let curve_handle = hits.curve_handle; + if fade_corner.is_some() && resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } + if curve_handle.is_some() && resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + let gain_icon_at = |q: Pos2| { + let c = clip_at(track, sample_at(&s, tl, q.x).max(0)).filter(|_| inp.auto_view.is_none())?; + clip_gain_icon(&s, tl, lane, c).filter(|r| r.contains(q)).map(|_| c.clone()) + }; + if resp.hovered() && gain_icon_at(p).is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + let selector_press = eff == Tool::Selector && fade_corner.is_none() && curve_handle.is_none() && gain_icon_at(origin).is_none(); + if selector_press && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) { + place_insertion(app, ui, &s, track.id, at, mods.shift); + } + let gain_drag = if resp.drag_started() { gain_icon_at(origin) } else { None }; + let cx = LaneCtx { inp, hits, mods, p, at, eff, fade_corner, selector_press }; + start_gesture(app, ui, &cx, &resp, gain_drag); + drag_clip_gain(app, ui, &cx, &resp); + lane_click(app, ui, &cx, &resp); +} + fn place_insertion(app: &mut SoundApp, ui: &Ui, s: &Session, track: TrackId, at: Samples, extend: bool) { let at = snap(s, at); let sel = s.edit.selection; @@ -423,7 +511,9 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { } return; }; - let ids = json!([c.0]); + // A clip of the edit selection acts on the whole selection (the commands default to it); any other clip acts alone. + let in_selection = clip_in_selection(app.engine.session(), c); + let target = if in_selection { json!({}) } else { json!({"clips": [c.0]}) }; for (label, cmd) in [ ("Mute/Unmute", "edit.mute_clips"), ("Edit Lock/Unlock", "clip.edit_lock"), @@ -434,7 +524,7 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { ("Send to Back", "clip.send_to_back"), ] { if ui.button(label).clicked() { - let _ = app.run(cmd, json!({"clips": ids})); + let _ = app.run(cmd, target.clone()); } } ui.separator(); @@ -443,7 +533,28 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { app.dialogs.open_rename_clip(c, &name); } if ui.button("Delete").clicked() { - let _ = app.run("edit.select", json!({"clips": ids})); - let _ = app.run("edit.clear", json!({})); + let s = app.engine.session(); + let selected: Vec = s.edit.selected_clips.iter().map(|id| id.0).collect(); + let track: Vec = 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)) +} diff --git a/crates/ui-egui/src/edit_window/layout.rs b/crates/ui-egui/src/edit_window/layout.rs index afc114bb..4af86e35 100644 --- a/crates/ui-egui/src/edit_window/layout.rs +++ b/crates/ui-egui/src/edit_window/layout.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Rect, Samples, Session, Track, pos2}; pub const HEADER_W: f32 = 136.0; @@ -70,6 +70,11 @@ impl EditLayout { pub fn scroll_by(&mut self, dy: f32, view_h: f32) { self.scroll_y = (self.scroll_y + dy).clamp(0.0, (self.content_h - view_h + 40.0).max(0.0)); } + + /// Clears the vertical scroll position Zoom Toggle would restore, so Cancel Zoom Toggle keeps the current scroll. + pub(crate) fn forget_zoom_back(&mut self) { + self.zoom_back = None; + } } pub fn rect([x0, y0, x1, y1]: [f32; 4]) -> Rect { diff --git a/crates/ui-egui/src/edit_window/overlay.rs b/crates/ui-egui/src/edit_window/overlay.rs index 985e392c..d5a3934a 100644 --- a/crates/ui-egui/src/edit_window/overlay.rs +++ b/crates/ui-egui/src/edit_window/overlay.rs @@ -2,7 +2,7 @@ use super::drag::drag_plan; use super::input::midi_clip_span; use super::layout::x_of; use super::scroll::{FollowFrame, follow_update}; -use super::*; +use super::{Color32, Gesture, Rect, Samples, Shape, SoundApp, Stroke, StrokeKind, Tokens, TrackId, Ui, json, ops, pos2, rgb}; /// Selection overlay, insertion point and playhead across rulers and tracks. /// While playing, auto-scroll keeps the playhead in view for the follow diff --git a/crates/ui-egui/src/edit_window/rulers.rs b/crates/ui-egui/src/edit_window/rulers.rs index 250549cc..c665f907 100644 --- a/crates/ui-egui/src/edit_window/rulers.rs +++ b/crates/ui-egui/src/edit_window/rulers.rs @@ -1,6 +1,9 @@ use super::input::snap; use super::layout::{COLUMN_TITLES_H, COLUMNS, Column, HEADER_W, RULER_H, edit_columns, header_width, sample_at, x_of}; -use super::*; +use super::{ + Align2, Color32, GridValue, NoteValue, Range, Rect, Samples, Sense, Session, Shape, SoundApp, Stroke, TimeFormat, Tokens, Ui, bold, + format_position, json, pos2, regular, rgb, vec2, +}; // ---- rulers ---------------------------------------------------------------------------------- @@ -170,7 +173,7 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, } else { soundcraft_time::FrameRate::Fps25 }; - timebase_ruler(ui, &painter, &s, tl, row, view, TimeFormat::Timecode, alt, i + 1 == rulers.len()); + timebase_ruler(&painter, &s, &RulerRow { tl, row, view, bottom: i + 1 == rulers.len() }, TimeFormat::Timecode, alt); } "markers" | "markers2" | "markers3" | "markers4" | "markers5" | "chords" => { let lane: u8 = match id.as_str() { @@ -225,7 +228,7 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, } _ => { if let Some(f) = fmt { - timebase_ruler(ui, &painter, &s, tl, row, view, f, s.frame_rate, i + 1 == rulers.len()); + timebase_ruler(&painter, &s, &RulerRow { tl, row, view, bottom: i + 1 == rulers.len() }, f, s.frame_rate); // Clicking a timebase ruler locates. let resp = ui.interact(row, ui.scope_id().with(("ruler", id.as_str())), Sense::click_and_drag()); if (resp.clicked() || resp.dragged()) @@ -247,17 +250,17 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, ui.painter().line_segment([pos2(area.min.x, area.max.y - 1.0), pos2(area.max.x, area.max.y - 1.0)], Stroke::new(1.0, t.border)); } -fn timebase_ruler( - _ui: &Ui, - painter: &egui::Painter, - s: &Session, +/// Where a timebase ruler sits: the timeline, its row and the visible range, and whether it is the bottom ruler. +#[derive(Clone, Copy)] +struct RulerRow { tl: Rect, row: Rect, view: Range, - f: TimeFormat, - rate: soundcraft_time::FrameRate, bottom: bool, -) { +} + +fn timebase_ruler(painter: &egui::Painter, s: &Session, place: &RulerRow, f: TimeFormat, rate: soundcraft_time::FrameRate) { + let RulerRow { tl, row, view, bottom } = *place; let t = Tokens::current(); let sr = s.sample_rate; let spp = s.edit.zoom.samples_per_px; diff --git a/crates/ui-egui/src/edit_window/scroll.rs b/crates/ui-egui/src/edit_window/scroll.rs index c54b34b7..ba0b9b72 100644 --- a/crates/ui-egui/src/edit_window/scroll.rs +++ b/crates/ui-egui/src/edit_window/scroll.rs @@ -1,7 +1,7 @@ use super::layout::track_height; use super::tracks::in_edit_window; use super::universe::content_end; -use super::*; +use super::{Rect, Samples, Sense, SoundApp, Tokens, TrackId, Ui, json, pos2, vec2}; /// Scroll Into View: track `id` to the top, as far as the scroll bar reaches. A track the Edit /// window doesn't show (hidden, or in a closed folder) leaves the view where it is. @@ -75,8 +75,13 @@ pub(super) fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { } } -/// Zoom Toggle sized to the track area on screen. +/// Zoom Toggle sized to the track area on screen. With Alt held (an Alt-click on the button) it cancels Zoom Toggle +/// instead and keeps the current view. pub fn zoom_toggle(app: &mut SoundApp) { + if app.docking_context.as_ref().is_some_and(|c| c.input(|i| i.modifiers.alt)) { + let _ = app.run("view.zoom_toggle_cancel", json!({})); + return; + } let [x0, y0, x1, y1] = app.edit_layout.timeline; let _ = app.run("view.zoom_toggle", json!({"width_px": x1 - x0, "height_px": y1 - y0})); } diff --git a/crates/ui-egui/src/edit_window/tests.rs b/crates/ui-egui/src/edit_window/tests.rs index f7f94bbe..99d7dc45 100644 --- a/crates/ui-egui/src/edit_window/tests.rs +++ b/crates/ui-egui/src/edit_window/tests.rs @@ -246,6 +246,16 @@ fn hold_clears_when_playhead_returns_or_transport_stops() { ); } +/// Cancel Zoom Toggle with Zoom Toggle already off keeps the scroll position a pending toggle-off restores. +#[test] +fn a_cancel_with_zoom_toggle_off_keeps_the_scroll_to_restore() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default()); + app.edit_layout.zoom_back = Some(120.0); + let v = app.run("view.zoom_toggle_cancel", json!({})).unwrap(); + assert_eq!(v["canceled"], json!(false)); + assert_eq!(app.edit_layout.zoom_back, Some(120.0)); +} + fn harness(n: usize) -> Harness<'static, SoundApp> { let mut engine = soundcraft_engine::Engine::default(); engine.execute("track.new", &json!({"count": n})).unwrap(); @@ -591,128 +601,129 @@ fn waveform_geometry(h: &Harness, clip_rect: Rect) -> Vec> { .collect() } +/// Replace the clip's mono source with a stereo one whose right channel is half the left, and make the track stereo. +fn make_stereo(h: &mut Harness<'static, SoundApp>, tid: soundcraft_model::TrackId, source: soundcraft_model::SourceId) { + let audio = h.state().engine.session().pool.get(source).unwrap().clone(); + let left = audio.buffer.channels[0].clone(); + let right = left.iter().map(|sample| sample * 0.5).collect(); + let audio = + soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: audio.buffer.sample_rate, channels: vec![left, right] }); + let s = h.state_mut().engine.session_mut(); + s.pool.insert(source, std::sync::Arc::new(audio)); + s.track_mut(tid).unwrap().format = soundcraft_model::ChannelFormat::Stereo; +} + +/// Where a fade drag presses and where it releases: the curve midpoint (bend) or the fade's end (length). +fn fade_drag_points( + session: &soundcraft_model::Session, + tl: Rect, + lane: Rect, + clip: soundcraft_model::ClipId, + fade_in: bool, + bend: bool, +) -> (Pos2, Pos2) { + let c = session.find_clip(clip).unwrap().1; + let (xs, xe) = (x_of(session, tl, c.start), x_of(session, tl, c.end())); + if bend { + let midpoint = fade_midpoint(session, tl, lane, c, fade_in).unwrap(); + (midpoint, midpoint - vec2(0.0, 16.0)) + } else { + (pos2(if fade_in { xs + 6.0 } else { xe - 6.0 }, lane.min.y + 4.0), pos2(xs + (xe - xs) * if fade_in { 0.7 } else { 0.3 }, lane.min.y + 4.0)) + } +} + +fn fade_waveform_case(channels: usize, fade_in: bool, bend: bool, mode: &str) { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let (tid, _) = h.state().engine.session().find_clip(clip.id).unwrap(); + let neighbour = h.state().engine.session().track(tid).unwrap().clips().iter().find(|c| c.id != clip.id && c.start >= clip.end()).unwrap().clone(); + let source = clip.source().unwrap(); + if channels == 2 { + make_stereo(&mut h, tid, source); + } + if mode == "power" { + h.state_mut().engine.execute("view.waveform_power", &json!({})).unwrap(); + } + h.state_mut().engine.execute("view.waveform_rectified", &json!({"value": mode == "rectified"})).unwrap(); + h.state_mut().engine.execute("view.waveform_outlines", &json!({"value": mode == "outlines"})).unwrap(); + h.state_mut().engine.execute("view.waveform_zoom", &json!({"value": 1.4})).unwrap(); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.gain_db = -3.0; + c.gain_env = vec![(0, -3.0), (c.length, 3.0)]; + let target = soundcraft_model::Fade { + len: if bend { c.length * 2 / 3 } else { c.length / 4 }, + shape: soundcraft_model::FadeShape::SCurve, + curvature: 0.0, + }; + let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: 0.2 }; + (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; + h.step(); + let before = h.state().engine.session().clone(); + let undo_len = h.state().engine.undo_history().len(); + let audio = before.pool.get(source).unwrap().clone(); + let (xs, xe) = (x_of(&before, tl, clip.start), x_of(&before, tl, clip.end())); + let clip_rect = Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y)).intersect(tl); + let neighbour_rect = + Rect::from_min_max(pos2(x_of(&before, tl, neighbour.start), lane.min.y), pos2(x_of(&before, tl, neighbour.end()), lane.max.y)).intersect(tl); + let original = waveform_geometry(&h, clip_rect); + let neighbour_wave = waveform_geometry(&h, neighbour_rect); + assert_eq!(original.len(), channels); + let side = if fade_in { "in" } else { "out" }; + let operation = if bend { "bend" } else { "length" }; + let screenshot = channels == 1 && mode == "peak"; + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-before")); + } + let (press, to) = fade_drag_points(&before, tl, lane, clip.id, fade_in, bend); + h.hover_at(press); + h.drag_at(press); + h.hover_at(to); + h.step(); + h.step(); + assert!(if bend { + matches!(h.state().gesture, Some(Gesture::FadeCurve { .. })) + } else { + matches!(h.state().gesture, Some(Gesture::Fade { .. })) + }); + let preview = waveform_geometry(&h, clip_rect); + assert_eq!(preview.len(), channels); + for channel in 0..channels { + assert_ne!( + preview[channel], original[channel], + "the waveform must respond while the button is held: {side}, {operation}, {mode}, channel={channel}" + ); + } + assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); + assert_eq!(h.state().engine.session(), &before); + assert_eq!(h.state().engine.undo_history().len(), undo_len); + assert!(std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio), "preview must retain cached media and peaks"); + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-preview")); + } + h.drop_at(to); + h.step(); + assert!(h.state().gesture.is_none()); + assert_eq!(waveform_geometry(&h, clip_rect), preview, "release must not cause a waveform jump"); + assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + assert!(std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio)); + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-released")); + } + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + h.step(); + assert_eq!(waveform_geometry(&h, clip_rect), original); +} + #[test] fn waveforms_follow_fade_drags_before_release_and_match_the_committed_waveform() { for channels in [1, 2] { for fade_in in [true, false] { for bend in [false, true] { for mode in ["peak", "power", "rectified", "outlines"] { - let mut h = smart_demo(); - let tl = timeline(&h); - let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); - let (tid, _) = h.state().engine.session().find_clip(clip.id).unwrap(); - let neighbour = h - .state() - .engine - .session() - .track(tid) - .unwrap() - .clips() - .iter() - .find(|c| c.id != clip.id && c.start >= clip.end()) - .unwrap() - .clone(); - let source = clip.source().unwrap(); - if channels == 2 { - let audio = h.state().engine.session().pool.get(source).unwrap().clone(); - let left = audio.buffer.channels[0].clone(); - let right = left.iter().map(|sample| sample * 0.5).collect(); - let audio = soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { - sample_rate: audio.buffer.sample_rate, - channels: vec![left, right], - }); - let s = h.state_mut().engine.session_mut(); - s.pool.insert(source, std::sync::Arc::new(audio)); - s.track_mut(tid).unwrap().format = soundcraft_model::ChannelFormat::Stereo; - } - if mode == "power" { - h.state_mut().engine.execute("view.waveform_power", &json!({})).unwrap(); - } - h.state_mut().engine.execute("view.waveform_rectified", &json!({"value": mode == "rectified"})).unwrap(); - h.state_mut().engine.execute("view.waveform_outlines", &json!({"value": mode == "outlines"})).unwrap(); - h.state_mut().engine.execute("view.waveform_zoom", &json!({"value": 1.4})).unwrap(); - let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); - c.gain_db = -3.0; - c.gain_env = vec![(0, -3.0), (c.length, 3.0)]; - let target = soundcraft_model::Fade { - len: if bend { c.length * 2 / 3 } else { c.length / 4 }, - shape: soundcraft_model::FadeShape::SCurve, - curvature: 0.0, - }; - let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: 0.2 }; - (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; - h.step(); - let before = h.state().engine.session().clone(); - let undo_len = h.state().engine.undo_history().len(); - let audio = before.pool.get(source).unwrap().clone(); - let (xs, xe) = (x_of(&before, tl, clip.start), x_of(&before, tl, clip.end())); - let clip_rect = Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y)).intersect(tl); - let neighbour_rect = Rect::from_min_max( - pos2(x_of(&before, tl, neighbour.start), lane.min.y), - pos2(x_of(&before, tl, neighbour.end()), lane.max.y), - ) - .intersect(tl); - let original = waveform_geometry(&h, clip_rect); - let neighbour_wave = waveform_geometry(&h, neighbour_rect); - assert_eq!(original.len(), channels); - let side = if fade_in { "in" } else { "out" }; - let operation = if bend { "bend" } else { "length" }; - let screenshot = channels == 1 && mode == "peak"; - if screenshot { - fade_shot(&mut h, &format!("waveform-{side}-{operation}-before")); - } - let (press, to) = if bend { - let c = before.find_clip(clip.id).unwrap().1; - let midpoint = fade_midpoint(&before, tl, lane, c, fade_in).unwrap(); - (midpoint, midpoint - vec2(0.0, 16.0)) - } else { - ( - pos2(if fade_in { xs + 6.0 } else { xe - 6.0 }, lane.min.y + 4.0), - pos2(xs + (xe - xs) * if fade_in { 0.7 } else { 0.3 }, lane.min.y + 4.0), - ) - }; - h.hover_at(press); - h.drag_at(press); - h.hover_at(to); - h.step(); - h.step(); - assert!(if bend { - matches!(h.state().gesture, Some(Gesture::FadeCurve { .. })) - } else { - matches!(h.state().gesture, Some(Gesture::Fade { .. })) - }); - let preview = waveform_geometry(&h, clip_rect); - assert_eq!(preview.len(), channels); - for channel in 0..channels { - assert_ne!( - preview[channel], original[channel], - "the waveform must respond while the button is held: {side}, {operation}, {mode}, channel={channel}" - ); - } - assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); - assert_eq!(h.state().engine.session(), &before); - assert_eq!(h.state().engine.undo_history().len(), undo_len); - assert!( - std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio), - "preview must retain cached media and peaks" - ); - if screenshot { - fade_shot(&mut h, &format!("waveform-{side}-{operation}-preview")); - } - h.drop_at(to); - h.step(); - assert!(h.state().gesture.is_none()); - assert_eq!(waveform_geometry(&h, clip_rect), preview, "release must not cause a waveform jump"); - assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); - assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); - assert!(std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio)); - if screenshot { - fade_shot(&mut h, &format!("waveform-{side}-{operation}-released")); - } - assert!(h.state_mut().engine.undo()); - assert_eq!(h.state().engine.session(), &before); - h.step(); - assert_eq!(waveform_geometry(&h, clip_rect), original); + fade_waveform_case(channels, fade_in, bend, mode); } } } @@ -998,85 +1009,100 @@ fn fade_midpoint_handles_remain_visible_and_can_be_grabbed_again() { } } +/// `session` with the clip's fade-in (or fade-out) curvature set. +fn with_curvature(session: &soundcraft_model::Session, clip: soundcraft_model::ClipId, fade_in: bool, curvature: f32) -> soundcraft_model::Session { + let mut expected = session.clone(); + let c = expected.find_clip_mut(clip).unwrap(); + if fade_in { + c.fade_in.curvature = curvature; + } else { + c.fade_out.curvature = curvature; + } + expected +} + +/// The screenshot name for a fade bend drag. +fn bend_shot_name(fade_in: bool, dy: f32, stage: &str) -> String { + let side = if fade_in { "in" } else { "out" }; + let direction = if dy < 0.0 { "up" } else { "down" }; + format!("fade-{side}-bend-{direction}-{stage}") +} + +fn fade_midpoint_bend_case(fade_in: bool, show_name: bool, dy: f32) { + let mut h = smart_demo(); + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.step(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + let target = soundcraft_model::Fade { len: c.length * 2 / 3, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; + let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: -0.2 }; + (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; + h.step(); + let before = h.state().engine.session().clone(); + let clip_rect = Rect::from_min_max(pos2(x_of(&before, tl, clip.start), lane.min.y), pos2(x_of(&before, tl, clip.end()), lane.max.y)); + let original = fade_curves(&h, clip_rect); + let (edited, opposite) = if fade_in { (0, 1) } else { (1, 0) }; + let middle = original[edited].0.points.len() / 2; + let midpoint = original[edited].0.points[middle]; + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(midpoint); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + click(&mut h, midpoint); + assert_eq!(h.state().engine.session(), &before, "clicking the curve control should not change the selection"); + h.drag_at(midpoint); + let to = midpoint + vec2(0.0, dy); + h.hover_at(to); + h.step(); + h.step(); // Paint the frame requested when the drag gesture starts. + let curvature = match h.state().gesture { + Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) if id == clip.id && side == fade_in => curvature, + _ => panic!("the midpoint must start a curve gesture"), + }; + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + assert!(if dy < 0.0 { curvature > 0.0 } else { curvature < 0.0 }); + let preview = fade_curves(&h, clip_rect); + assert_eq!(preview.len(), 2); + assert_eq!(preview[opposite], original[opposite]); + assert_eq!(preview[edited].0.points.first(), original[edited].0.points.first()); + assert_eq!(preview[edited].0.points.last(), original[edited].0.points.last()); + assert_eq!(preview[edited].0.points[middle].x, midpoint.x); + assert!( + (preview[edited].0.points[middle].y - (midpoint.y + dy)).abs() < 0.001, + "side={fade_in}, names={show_name}, dy={dy}, midpoint={midpoint:?}, drawn={:?}, gesture={:?}", + preview[edited].0.points[middle], + h.state().gesture + ); + assert_eq!(h.state().engine.session(), &before); + if show_name { + fade_shot(&mut h, &bend_shot_name(fade_in, dy, "preview")); + } + h.drop_at(to); + h.step(); + let expected = with_curvature(&before, clip.id, fade_in, curvature); + assert_eq!(h.state().engine.session(), &expected); + assert_eq!(fade_curves(&h, clip_rect), preview, "the released envelope must match the preview"); + let rel = if fade_in { target.len / 2 } else { clip.length - target.len / 2 }; + let previous_gain = before.find_clip(clip.id).unwrap().1.gain_at(rel); + let new_gain = expected.find_clip(clip.id).unwrap().1.gain_at(rel); + assert!(if dy < 0.0 { new_gain > previous_gain } else { new_gain < previous_gain }); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + if show_name { + fade_shot(&mut h, &bend_shot_name(fade_in, dy, "released")); + } + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + assert!(h.state_mut().engine.redo()); + assert_eq!(h.state().engine.session(), &expected); +} + #[test] fn fade_midpoints_drag_vertically_without_moving_the_endpoints() { for fade_in in [true, false] { for show_name in [true, false] { for dy in [-16.0, 16.0] { - let mut h = smart_demo(); - h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); - h.step(); - let tl = timeline(&h); - let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); - let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); - let target = soundcraft_model::Fade { len: c.length * 2 / 3, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; - let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: -0.2 }; - (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; - h.step(); - let before = h.state().engine.session().clone(); - let clip_rect = Rect::from_min_max(pos2(x_of(&before, tl, clip.start), lane.min.y), pos2(x_of(&before, tl, clip.end()), lane.max.y)); - let original = fade_curves(&h, clip_rect); - let (edited, opposite) = if fade_in { (0, 1) } else { (1, 0) }; - let middle = original[edited].0.points.len() / 2; - let midpoint = original[edited].0.points[middle]; - let undo_len = h.state().engine.undo_history().len(); - h.hover_at(midpoint); - h.step(); - assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); - click(&mut h, midpoint); - assert_eq!(h.state().engine.session(), &before, "clicking the curve control should not change the selection"); - h.drag_at(midpoint); - let to = midpoint + vec2(0.0, dy); - h.hover_at(to); - h.step(); - h.step(); // Paint the frame requested when the drag gesture starts. - let curvature = match h.state().gesture { - Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) if id == clip.id && side == fade_in => curvature, - _ => panic!("the midpoint must start a curve gesture"), - }; - assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); - assert!(if dy < 0.0 { curvature > 0.0 } else { curvature < 0.0 }); - let preview = fade_curves(&h, clip_rect); - assert_eq!(preview.len(), 2); - assert_eq!(preview[opposite], original[opposite]); - assert_eq!(preview[edited].0.points.first(), original[edited].0.points.first()); - assert_eq!(preview[edited].0.points.last(), original[edited].0.points.last()); - assert_eq!(preview[edited].0.points[middle].x, midpoint.x); - assert!( - (preview[edited].0.points[middle].y - (midpoint.y + dy)).abs() < 0.001, - "side={fade_in}, names={show_name}, dy={dy}, midpoint={midpoint:?}, drawn={:?}, gesture={:?}", - preview[edited].0.points[middle], - h.state().gesture - ); - assert_eq!(h.state().engine.session(), &before); - let side = if fade_in { "in" } else { "out" }; - let direction = if dy < 0.0 { "up" } else { "down" }; - if show_name { - fade_shot(&mut h, &format!("fade-{side}-bend-{direction}-preview")); - } - h.drop_at(to); - h.step(); - let mut expected = before.clone(); - let c = expected.find_clip_mut(clip.id).unwrap(); - if fade_in { - c.fade_in.curvature = curvature; - } else { - c.fade_out.curvature = curvature; - } - assert_eq!(h.state().engine.session(), &expected); - assert_eq!(fade_curves(&h, clip_rect), preview, "the released envelope must match the preview"); - let rel = if fade_in { target.len / 2 } else { clip.length - target.len / 2 }; - let previous_gain = before.find_clip(clip.id).unwrap().1.gain_at(rel); - let new_gain = expected.find_clip(clip.id).unwrap().1.gain_at(rel); - assert!(if dy < 0.0 { new_gain > previous_gain } else { new_gain < previous_gain }); - assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); - if show_name { - fade_shot(&mut h, &format!("fade-{side}-bend-{direction}-released")); - } - assert!(h.state_mut().engine.undo()); - assert_eq!(h.state().engine.session(), &before); - assert!(h.state_mut().engine.redo()); - assert_eq!(h.state().engine.session(), &expected); + fade_midpoint_bend_case(fade_in, show_name, dy); } } } diff --git a/crates/ui-egui/src/edit_window/tracks.rs b/crates/ui-egui/src/edit_window/tracks.rs index 8eb6c593..8da99821 100644 --- a/crates/ui-egui/src/edit_window/tracks.rs +++ b/crates/ui-egui/src/edit_window/tracks.rs @@ -1,9 +1,12 @@ use super::automation::draw_automation; -use super::clips::{draw_clip, draw_live_take, draw_strip_silence, grid_lines}; +use super::clips::{ClipLane, draw_clip, draw_live_take, draw_strip_silence, grid_lines}; use super::header::{header_column, track_header}; use super::input::lane_interaction; use super::layout::{Column, HEADER_W, LANE_H, STRIP_W, lane_rect, sample_at, x_of}; -use super::*; +use super::{ + Align2, AutoParam, Range, Rect, Sense, Session, SoundApp, Stroke, Tokens, Track, TrackHeight, TrackId, TrackKind, Ui, json, ops, pos2, regular, + rgb, vec2, +}; pub(super) fn in_edit_window(s: &Session, t: &Track) -> bool { ops::shown(s, t) @@ -90,7 +93,7 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, if !clip.range().overlaps(&view) { continue; } - draw_clip(app, &painter, &s, track, sorted, clip, tl, lane, auto_view.is_some()); + draw_clip(&ClipLane { app, painter: &painter, s: &s, track, sorted, tl, lane }, clip, auto_view.is_some()); } draw_live_take(app, &painter, &s, track, tl, lane); crate::video_track::draw_lane(app, &painter, &s, track, tl, lane); @@ -140,7 +143,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai let sorted = track.clips().is_sorted_by_key(|c| c.start); let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); for c in pl.clips.iter().filter(|c| c.range().overlaps(&view)) { - draw_clip(app, &painter, &s, track, sorted, c, tl, lane, false); + draw_clip(&ClipLane { app, painter: &painter, s: &s, track, sorted, tl, lane }, c, false); } let resp = ui.interact(lane, ui.scope_id().with(("pl_lane", track.id.0, i)), Sense::click()); if resp.clicked() diff --git a/crates/ui-egui/src/edit_window/universe.rs b/crates/ui-egui/src/edit_window/universe.rs index 3175caaa..3a9331a8 100644 --- a/crates/ui-egui/src/edit_window/universe.rs +++ b/crates/ui-egui/src/edit_window/universe.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, Rect, Samples, Sense, Session, SoundApp, Stroke, StrokeKind, Tokens, Track, Ui, json, pos2, rgb, vec2}; /// The Universe's clip blocks for one document revision and area: each track's clips merged into /// runs of one colour. A drum edit holds tens of thousands of clips a pixel or less wide here; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a84be7e4..1618b315 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4,6 +4,15 @@ //! executing commands. It can be replaced without touching anything below it. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +/// SoundCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.soundcraft.png")), + accent: Some([0x1f, 0xa5, 0xc9]), + ..craft_update::suite::SOUNDCRAFT +}; + pub mod appearance; pub mod bounce; pub mod control; @@ -235,6 +244,7 @@ pub enum Gesture { FadeCurve { clip: soundcraft_model::ClipId, fade_in: bool, curvature: f32, anchor: (f32, f32), height: f32 }, Scrub { last: Samples }, Fader { track: TrackId, start_db: f32 }, + SendFader { track: TrackId, slot: usize }, ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 }, Pencil { track: TrackId, points: Vec<(Samples, f32)> }, Breakpoint { track: TrackId, param: soundcraft_model::AutoParam, at: Samples }, @@ -384,6 +394,8 @@ pub struct SoundApp { pub arrange_request: bool, /// Tracks whose automation was touched during this pass (Touch/Latch writing). touched: std::collections::HashSet, + /// Sends whose level was touched during this pass (Touch/Latch writing), as (track, slot). + touched_sends: std::collections::HashSet<(TrackId, usize)>, /// The pass's last point; `i64::MIN` after a stop or a jump, so the next point erases nothing it skipped. last_write_at: Samples, /// Plugins changed in their editors whose state the session hasn't taken yet (see `editor_states_due`). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fc821b42..450c3af0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -518,6 +518,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option { + track: &'a Track, + id: TrackId, + r: Rect, + w: f32, + x0: f32, + inner_w: f32, + narrow: bool, + selected: bool, + views: Vec, + all: bool, +} + +impl StripCtx<'_> { + /// Whether the mixer shows the section named `view`. + fn shows(&self, view: &str) -> bool { + self.all || self.views.iter().any(|x| x == view) + } +} + fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>, scroll_here: bool) { let t = Tokens::current(); let session = app.engine.session_arc(); @@ -173,12 +197,30 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let inner_w = w - 8.0; let x0 = r.min.x + 4.0; let views = app.ui.mix_views.clone(); - let has = |v: &str| views.iter().any(|x| x == v); - let all = has("all"); + let all = views.iter().any(|x| x == "all"); + let cx = StripCtx { track, id, r, w, x0, inner_w, narrow, selected, views, all }; // Preamp, instrument and object rows keep their height on every strip so sections line up; // their controls only appear on tracks with an input. + y = preamp_section(app, ui, &cx, y); + y = instrument_section(app, ui, &cx, y); + y = object_section(app, ui, &cx, y); + y = slot_sections(app, ui, &cx, y); + y = eq_section(ui, &cx, y); + y = comments_section(app, ui, &cx, y); + y = io_section(app, ui, &cx, y); + y = group_selector(app, ui, &cx, y); + y = pan_section(app, ui, &cx, y); + y = toggle_rows(app, ui, &cx, y); + fader_and_meter(app, ui, &cx, y, snap); + name_and_readout(app, ui, &cx); +} + +/// Input gain stage ahead of the inserts (the track's trim). +fn preamp_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, narrow, x0, inner_w, .. } = *cx; + let t = Tokens::current(); let has_input = matches!(track.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Midi); - if all || has("mic_preamps") { + if cx.shows("mic_preamps") { // Input gain stage ahead of the inserts (the track's trim). let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 34.0)); inset(ui.painter(), sec, t.strip_section); @@ -199,7 +241,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } - if all || has("instruments") { + y +} + +/// The instrument slot. +fn instrument_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, narrow, x0, inner_w, .. } = *cx; + let t = Tokens::current(); + if cx.shows("instruments") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 34.0)); inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, "INSTRUMENT", narrow); @@ -215,7 +264,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } - if all || has("object") { + y +} + +/// The object/bed switch. +fn object_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, x0, inner_w, .. } = *cx; + let has_input = matches!(track.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Midi); + if cx.shows("object") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 20.0)); if has_input { let on = app.engine.session().edit.flag(&format!("object.{}", id.0)); @@ -227,13 +283,20 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } + y +} + +/// The insert and send sections. +fn slot_sections(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, narrow, x0, inner_w, .. } = *cx; + let t = Tokens::current(); for (key, title, off, is_send) in [ ("inserts_ae", "INSERTS A-E", 0usize, false), ("inserts_fj", "INSERTS F-J", 5, false), ("sends_ae", "SENDS A-E", 0, true), ("sends_fj", "SENDS F-J", 5, true), ] { - if !(all || has(key)) { + if !cx.shows(key) { continue; } let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 5.0 * 17.0)); @@ -249,7 +312,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } - if all || has("eq_curve") { + y +} + +/// The EQ curve. +fn eq_section(ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, x0, inner_w, .. } = *cx; + let t = Tokens::current(); + if cx.shows("eq_curve") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 40.0)); ui.painter().rect_filled(sec, 2.0, Color32::from_rgb(14, 18, 22)); let eq = track.mixer.inserts.iter().flatten().find(|i| i.plugin == "eq_7band" || i.plugin == "eq_1band"); @@ -278,7 +348,13 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } - if all || has("comments") { + y +} + +/// The comments field. +fn comments_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, narrow, x0, inner_w, .. } = *cx; + if cx.shows("comments") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 44.0)); let mut c = track.comments.clone(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sec)); @@ -292,7 +368,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } - if all || has("io") { + y +} + +/// Input, output and automation mode selectors. +fn io_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, narrow, x0, inner_w, .. } = *cx; + let t = Tokens::current(); + if cx.shows("io") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 2.0 * 18.0 + 30.0)); inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, "I / O", narrow); @@ -357,6 +440,13 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh }); y = sec.max.y + 4.0; } + y +} + +/// The group selector. +fn group_selector(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, narrow, x0, inner_w, .. } = *cx; + let t = Tokens::current(); // Group selector. let no_group = if narrow { "nogrp" } else { "no group" }; let gname = app.engine.session().groups.iter().find(|g| g.members.contains(&id)).map_or(no_group.to_string(), |g| g.name.clone()); @@ -371,6 +461,13 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh SelectorLabels { name: &format!("Group for {}", track.name), value: &gname, tooltip: "Group" }, ); y += 22.0; + y +} + +/// Pan: a surround panner when the output is multichannel, else the stereo knobs. +fn pan_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, x0, inner_w, .. } = *cx; + let t = Tokens::current(); // Pan: a surround panner when the output is multichannel, else the stereo knobs. let out_fmt = output_format(app, track); let surround_out = @@ -429,6 +526,13 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y += 54.0; } + y +} + +/// Rec / Input / Solo / Mute. +fn toggle_rows(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, x0, inner_w, .. } = *cx; + let t = Tokens::current(); // Rec / Input / Solo / Mute. let bw = (inner_w - 6.0) / 2.0; let mut row = ui.new_child( @@ -461,9 +565,15 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } } y += 26.0; + y +} + +/// The fader and its meter. +fn fader_and_meter(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, y: f32, snap: Option<&MeterSnapshot>) { + let StripCtx { track, id, narrow, x0, inner_w, r, .. } = *cx; + let t = Tokens::current(); // Fader + meter. - let bottom_h = 64.0; - let fh = (r.max.y - bottom_h - y).max(120.0); + let fh = (r.max.y - STRIP_FOOT_H - y).max(120.0); let wide_meter = (if track.kind == TrackKind::Master { app.engine.session().main_format() } else { track.format }).channels() > 2; let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * if wide_meter { 0.45 } else { 0.6 }, fh)); if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.scope_id().with(("fader", id.0)), &format!("{} level", track.name)) { @@ -495,8 +605,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh if md.gr > 0.1 { ui.painter().text(pos2(mr.center().x, mr.min.y - 3.0), Align2::CENTER_BOTTOM, format!("-{:.0}", md.gr), regular(8.0), t.meter_yellow); } +} + +/// Volume readout, kind label and name plate. +fn name_and_readout(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>) { + let StripCtx { track, id, x0, inner_w, r, w, selected, .. } = *cx; + let t = Tokens::current(); // Volume readout and name. - let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - bottom_h + 4.0), vec2(inner_w - 8.0, 15.0)); + let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - STRIP_FOOT_H + 4.0), vec2(inner_w - 8.0, 15.0)); inset(ui.painter(), vr, t.counter_bg); let (_, entered) = editable_readout( ui, diff --git a/crates/ui-egui/src/mix_window/plugin_menu.rs b/crates/ui-egui/src/mix_window/plugin_menu.rs index c9923dc4..ea077887 100644 --- a/crates/ui-egui/src/mix_window/plugin_menu.rs +++ b/crates/ui-egui/src/mix_window/plugin_menu.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, Rect, SoundApp, TrackId, Ui, json, pos2, vec2}; /// An insert's menu: a search field that takes typing at once, then (until something is typed) /// Bypass and removal and the plugins by category and format. diff --git a/crates/ui-egui/src/mix_window/routing.rs b/crates/ui-egui/src/mix_window/routing.rs index 63e21f5b..176a8cba 100644 --- a/crates/ui-egui/src/mix_window/routing.rs +++ b/crates/ui-egui/src/mix_window/routing.rs @@ -1,5 +1,5 @@ use super::plugin_menu::{MenuSearch, menu_row}; -use super::*; +use super::{Route, SoundApp, Track, TrackId, Ui, json}; pub(crate) fn route_name(app: &SoundApp, r: &Route) -> String { match r { diff --git a/crates/ui-egui/src/mix_window/sends.rs b/crates/ui-egui/src/mix_window/sends.rs index 1a9dabdc..87cac59d 100644 --- a/crates/ui-egui/src/mix_window/sends.rs +++ b/crates/ui-egui/src/mix_window/sends.rs @@ -1,7 +1,10 @@ use super::plugin_menu::{MenuSearch, menu_row}; use super::routing::route_name; use super::slots::{COMPACT_SLOT, Mark, empty_slot, level_icon, used_slot}; -use super::*; +use super::{ + Align2, Rect, Route, Sense, SoundApp, Stroke, StrokeKind, Tokens, Track, TrackId, TrackKind, Ui, db_text, fader, json, pan_knob, pan_text, pos2, + regular, text_toggle, vec2, +}; use craft_ui::combo::Menu; /// Draw one send slot: the route area picks a destination, while the level area opens or edits the send level. @@ -43,6 +46,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us // applying it to the already-updated value made trackpad adjustment accelerate. let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift)); let db = dragged_send_db(s.level_db, dy, fine); + app.gesture = Some(crate::Gesture::SendFader { track: id, slot }); let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } @@ -117,6 +121,7 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) { // Distinct from the strip's "Send A level for " icon and from the slot's "Send level", so a // label query while the window is open still picks one node. if let Some(db) = fader(ui, fr, send.level_db, ui.scope_id().with("send_fader"), "Send level fader") { + app.gesture = Some(crate::Gesture::SendFader { track: id, slot }); let _ = app.engine.execute_merged( "mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), diff --git a/crates/ui-egui/src/mix_window/slots.rs b/crates/ui-egui/src/mix_window/slots.rs index 0c7e45cd..866b60eb 100644 --- a/crates/ui-egui/src/mix_window/slots.rs +++ b/crates/ui-egui/src/mix_window/slots.rs @@ -1,5 +1,5 @@ use super::plugin_menu::{instrument_menu, plugin_info, plugin_menu}; -use super::*; +use super::{Align2, Color32, Rect, Sense, SoundApp, Stroke, Tokens, Track, Ui, bold, groove, json, pos2, raised_key, shade, vec2}; /// An instrument track's INSTRUMENT row: click opens a hosted instrument's plugin window, /// right-click (or click, when none is set) picks another instrument. A built-in's parameters diff --git a/crates/ui-egui/src/panels/about.rs b/crates/ui-egui/src/panels/about.rs index 36074c25..504bf9f7 100644 --- a/crates/ui-egui/src/panels/about.rs +++ b/crates/ui-egui/src/panels/about.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, bold, mono, vec2}; /// About window tabs for version information, contributors and models. pub(super) fn about(app: &mut SoundApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/panels/clips.rs b/crates/ui-egui/src/panels/clips.rs index 4c127b97..cbdd28d5 100644 --- a/crates/ui-egui/src/panels/clips.rs +++ b/crates/ui-egui/src/panels/clips.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, ClipId, Rect, Sense, SoundApp, SourceId, Tokens, Ui, bold, json, panel_header, pos2, regular, rgb, vec2, with_pending_offset}; /// One row of the clip list: a whole file, or a clip on a track (positions in the session). #[derive(Clone, Copy)] diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index da145ca9..3d284f75 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, Tokens, Ui, json}; pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) { let hist = app.engine.undo_history(); diff --git a/crates/ui-egui/src/panels/memory.rs b/crates/ui-egui/src/panels/memory.rs index 313713aa..d849e382 100644 --- a/crates/ui-egui/src/panels/memory.rs +++ b/crates/ui-egui/src/panels/memory.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, Ui, json, mono}; /// One recallable memory-location row: number button, editable name, time, length, kind and delete. fn memory_location_row(app: &mut SoundApp, ui: &mut Ui, n: u32, name: &str, loc: &str, len: &str, kind: &str) { diff --git a/crates/ui-egui/src/panels/midi_editor.rs b/crates/ui-egui/src/panels/midi_editor.rs index 2515dbbb..2e48be01 100644 --- a/crates/ui-egui/src/panels/midi_editor.rs +++ b/crates/ui-egui/src/panels/midi_editor.rs @@ -69,6 +69,50 @@ fn is_black(p: i32) -> bool { } /// Draw the MIDI editor for the target clip, with an empty-state prompt that adds a clip on the selected MIDI track. +/// The piano roll's geometry: where it is drawn and how ticks and pitches map onto it. +#[derive(Clone, Copy)] +struct Roll<'a> { + sequence: &'a soundcraft_midi::Sequence, + roll: Rect, + keys: Rect, + vel: Rect, + px_per_tick: f32, + len_ticks: i64, + scroll: f32, + grid_ticks: i64, + rows: i32, +} + +impl Roll<'_> { + fn x_of(&self, tick: i64) -> f32 { + self.roll.min.x + tick as f32 * self.px_per_tick + } + + fn y_of(&self, pitch: i32) -> f32 { + self.roll.min.y + (127 - pitch) as f32 * ROW_H - self.scroll + } + + fn pitch_at(&self, y: f32) -> i32 { + 127 - ((y - self.roll.min.y + self.scroll) / ROW_H).floor() as i32 + } + + /// The note under `pos` and whether `pos` is on its resize edge. + fn hit(&self, pos: egui::Pos2) -> Option<(usize, bool)> { + self.sequence.notes.iter().enumerate().rev().find_map(|(i, n)| { + let r = Rect::from_min_max( + pos2(self.x_of(n.start), self.y_of(i32::from(n.pitch))), + pos2(self.x_of(n.start + n.length).max(self.x_of(n.start) + 3.0), self.y_of(i32::from(n.pitch)) + ROW_H), + ); + r.contains(pos).then_some((i, pos.x > r.max.x - 5.0)) + }) + } + + /// `tk` ticks, rounded to the grid. + fn snap(&self, tk: f32) -> i64 { + ((tk / self.grid_ticks as f32).round() as i64) * self.grid_ticks + } +} + pub fn show(app: &mut SoundApp, ui: &mut Ui) { // Painter-only contents do not advance the layout. Claim the resized // space, including the empty state, so the dock keeps its chosen height. @@ -80,24 +124,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(header, 0.0, t.panel_bg2); ui.painter().text(pos2(header.min.x + 8.0, header.center().y), Align2::LEFT_CENTER, "MIDI EDITOR", bold(11.5), t.header_text); let Some(cid) = target_clip(app) else { - let track = { - let s = app.engine.session(); - s.tracks.iter().find(|t| t.kind.is_midi() && s.edit.selected_tracks.contains(&t.id)).map(|t| (t.id, t.name.clone())) - }; - let msg = match &track { - Some((_, name)) => format!("Click to add a MIDI clip to {name} at the insertion point."), - None => "Select a MIDI clip (or a MIDI track) to edit its notes.".into(), - }; - ui.painter().text(full.center(), Align2::CENTER_CENTER, msg, regular(13.0), t.text_dim); - let body = Rect::from_min_max(pos2(full.min.x, header.max.y), full.max); - if let Some((id, _)) = track - && ui.interact(body, ui.scope_id().with("new_clip"), Sense::click()).clicked() - { - let sel = app.engine.session().edit.selection; - if let Ok(v) = app.run("midi.new_clip", json!({"track": id.0, "start": sel.start, "end": sel.end})) { - let _ = app.run("edit.select", json!({"clips": [v["clip"]]})); - } - } + empty_state(app, ui, full, header); return; }; let Some((track, clip)) = app.engine.session().find_clip(cid).map(|(t, c)| (t, c.clone())) else { return }; @@ -115,40 +142,8 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { regular(11.0), t.text_dim, ); - let grid_ticks = match app.engine.session().edit.grid { - soundcraft_time::GridValue::Note { value, dotted, triplet } => { - let mut g = value.ticks(); - if dotted { - g = g * 3 / 2; - } - if triplet { - g = g * 2 / 3; - } - g.max(1) - } - _ => TICKS_PER_QUARTER / 4, - }; - // Header toolbar: whole-clip MIDI operations. - let bar = Rect::from_min_max(pos2(header.max.x - 330.0, header.min.y + 1.0), pos2(header.max.x - 6.0, header.max.y - 1.0)); - let mut tb = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); - tb.spacing_mut().item_spacing.x = 4.0; - let mut op: Option<(&str, serde_json::Value)> = None; - if tb.small_button("Legato").clicked() { - op = Some(("event.change_duration", json!({"legato": 0}))); - } - if tb.small_button("Vel −10").clicked() { - op = Some(("event.change_velocity", json!({"add": -10}))); - } - if tb.small_button("Vel +10").clicked() { - op = Some(("event.change_velocity", json!({"add": 10}))); - } - if tb.small_button("Quantize").on_hover_text("Quantize to the grid").clicked() { - op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100}))); - } - if let Some((id, mut p)) = op { - p["clips"] = json!([cid.0]); - let _ = app.run(id, p); - } + let grid_ticks = grid_ticks_of(app); + midi_toolbar(app, ui, header, cid, grid_ticks); let s = app.engine.session_arc(); let sr = s.sample_rate; let base_tick = s.tempo.samples_to_ticks(clip.start, sr); @@ -170,15 +165,90 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { app.midi.scroll = app.midi.scroll.clamp(0.0, (128.0 * ROW_H - roll.height()).max(0.0)); let scroll = app.midi.scroll; let px_per_tick = roll.width() / len_ticks as f32; - let x_of = |tick: i64| roll.min.x + tick as f32 * px_per_tick; - let y_of = |pitch: i32| roll.min.y + (127 - pitch) as f32 * ROW_H - scroll; - let pitch_at = |y: f32| 127 - ((y - roll.min.y + scroll) / ROW_H).floor() as i32; + let g = Roll { sequence, roll, keys, vel, px_per_tick, len_ticks, scroll, grid_ticks, rows }; let painter = ui.painter().with_clip_rect(roll.union(keys)); + draw_roll_background(&painter, &g); + // Notes. + let base = egui::Color32::from_rgb(track_color[0], track_color[1], track_color[2]); + draw_notes(app, &painter, &g, base); + note_pointer(app, ui, &g, cid, s.edit.tool == Tool::Pencil); + note_keys(app, ui, &g, cid); + velocity_lane(app, ui, &g, cid, base, full.min.x + 6.0); +} + +/// The empty state: a prompt that adds a clip on the selected MIDI track when clicked. +fn empty_state(app: &mut SoundApp, ui: &mut Ui, full: Rect, header: Rect) { + let t = Tokens::current(); + let track = { + let s = app.engine.session(); + s.tracks.iter().find(|t| t.kind.is_midi() && s.edit.selected_tracks.contains(&t.id)).map(|t| (t.id, t.name.clone())) + }; + let msg = match &track { + Some((_, name)) => format!("Click to add a MIDI clip to {name} at the insertion point."), + None => "Select a MIDI clip (or a MIDI track) to edit its notes.".into(), + }; + ui.painter().text(full.center(), Align2::CENTER_CENTER, msg, regular(13.0), t.text_dim); + let body = Rect::from_min_max(pos2(full.min.x, header.max.y), full.max); + if let Some((id, _)) = track + && ui.interact(body, ui.scope_id().with("new_clip"), Sense::click()).clicked() + { + let sel = app.engine.session().edit.selection; + if let Ok(v) = app.run("midi.new_clip", json!({"track": id.0, "start": sel.start, "end": sel.end})) { + let _ = app.run("edit.select", json!({"clips": [v["clip"]]})); + } + } +} + +/// The grid step in ticks, from the Edit window's grid value. +fn grid_ticks_of(app: &SoundApp) -> i64 { + match app.engine.session().edit.grid { + soundcraft_time::GridValue::Note { value, dotted, triplet } => { + let mut g = value.ticks(); + if dotted { + g = g * 3 / 2; + } + if triplet { + g = g * 2 / 3; + } + g.max(1) + } + _ => TICKS_PER_QUARTER / 4, + } +} + +/// The header toolbar: whole-clip MIDI operations. +fn midi_toolbar(app: &mut SoundApp, ui: &mut Ui, header: Rect, cid: ClipId, grid_ticks: i64) { + // Header toolbar: whole-clip MIDI operations. + let bar = Rect::from_min_max(pos2(header.max.x - 330.0, header.min.y + 1.0), pos2(header.max.x - 6.0, header.max.y - 1.0)); + let mut tb = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); + tb.spacing_mut().item_spacing.x = 4.0; + let mut op: Option<(&str, serde_json::Value)> = None; + if tb.small_button("Legato").clicked() { + op = Some(("event.change_duration", json!({"legato": 0}))); + } + if tb.small_button("Vel −10").clicked() { + op = Some(("event.change_velocity", json!({"add": -10}))); + } + if tb.small_button("Vel +10").clicked() { + op = Some(("event.change_velocity", json!({"add": 10}))); + } + if tb.small_button("Quantize").on_hover_text("Quantize to the grid").clicked() { + op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100}))); + } + if let Some((id, mut p)) = op { + p["clips"] = json!([cid.0]); + let _ = app.run(id, p); + } +} + +/// Background rows, piano keys and the grid. +fn draw_roll_background(painter: &egui::Painter, g: &Roll<'_>) { + let Roll { roll, keys, px_per_tick, len_ticks, grid_ticks, rows, .. } = *g; // Background rows and keys. - let top = pitch_at(roll.min.y); + let top = g.pitch_at(roll.min.y); for r in 0..=rows + 1 { let pitch = top - r; - let y = y_of(pitch); + let y = g.y_of(pitch); let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), ROW_H)); painter.rect_filled(row, 0.0, if is_black(pitch) { Color32::from_rgb(30, 30, 32) } else { Color32::from_rgb(40, 40, 42) }); let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0)); @@ -198,7 +268,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let mut tick = 0; while tick <= len_ticks { let bar = tick % (TICKS_PER_QUARTER * 4) == 0; - let x = x_of(tick); + let x = g.x_of(tick); if grid_ticks as f32 * px_per_tick > 4.0 || bar { painter.line_segment( [pos2(x, roll.min.y), pos2(x, roll.max.y)], @@ -207,8 +277,12 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { } tick += grid_ticks; } +} + +/// The notes, with a drag in progress applied. +fn draw_notes(app: &SoundApp, painter: &egui::Painter, g: &Roll<'_>, base: Color32) { + let Roll { sequence, grid_ticks, .. } = *g; // Notes. - let base = egui::Color32::from_rgb(track_color[0], track_color[1], track_color[2]); let drag = app.midi.drag.clone(); for (i, n) in sequence.notes.iter().enumerate() { let (mut st, mut ln, mut p) = (n.start, n.length, i32::from(n.pitch)); @@ -224,7 +298,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { p = (p + d.dy_semi as i32).clamp(0, 127); } } - let r = Rect::from_min_max(pos2(x_of(st), y_of(p) + 1.0), pos2(x_of(st + ln).max(x_of(st) + 3.0), y_of(p) + ROW_H - 1.0)); + let r = Rect::from_min_max(pos2(g.x_of(st), g.y_of(p) + 1.0), pos2(g.x_of(st + ln).max(g.x_of(st) + 3.0), g.y_of(p) + ROW_H - 1.0)); let sel = app.midi.selected.contains(&i); let k = 0.45 + 0.55 * f32::from(n.velocity) / 127.0; let fill = if sel { @@ -234,22 +308,17 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { }; painter.rect(r, 2.0, fill, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); } +} + +/// Pointer interaction on the roll: note and band selection, drags and clicks. +fn note_pointer(app: &mut SoundApp, ui: &mut Ui, g: &Roll<'_>, cid: ClipId, pencil: bool) { + let Roll { sequence, roll, px_per_tick, grid_ticks, .. } = *g; // Interaction. let resp = ui.interact(roll, ui.scope_id().with(("roll", cid.0)), Sense::click_and_drag()); - let hit = |pos: egui::Pos2| -> Option<(usize, bool)> { - sequence.notes.iter().enumerate().rev().find_map(|(i, n)| { - let r = Rect::from_min_max( - pos2(x_of(n.start), y_of(i32::from(n.pitch))), - pos2(x_of(n.start + n.length).max(x_of(n.start) + 3.0), y_of(i32::from(n.pitch)) + ROW_H), - ); - r.contains(pos).then_some((i, pos.x > r.max.x - 5.0)) - }) - }; - let snap = |tk: f32| -> i64 { ((tk / grid_ticks as f32).round() as i64) * grid_ticks }; if resp.drag_started() && let Some(p) = resp.interact_pointer_pos() { - if let Some((i, edge)) = hit(p) { + if let Some((i, edge)) = g.hit(p) { if !app.midi.selected.contains(&i) { app.midi.selected = vec![i]; } @@ -275,8 +344,8 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { .enumerate() .filter(|(_, n)| { let r = Rect::from_min_max( - pos2(x_of(n.start), y_of(i32::from(n.pitch))), - pos2(x_of(n.start + n.length), y_of(i32::from(n.pitch)) + ROW_H), + pos2(g.x_of(n.start), g.y_of(i32::from(n.pitch))), + pos2(g.x_of(n.start + n.length), g.y_of(i32::from(n.pitch)) + ROW_H), ); r.intersects(band) }) @@ -288,7 +357,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { if resp.dragged() && let (Some(d), Some(p)) = (&mut app.midi.drag, resp.interact_pointer_pos()) { - d.dx_ticks = snap((p.x - d.start.x) / px_per_tick); + d.dx_ticks = g.snap((p.x - d.start.x) / px_per_tick); d.dy_semi = -((p.y - d.start.y) / ROW_H).round() as i64; } if resp.drag_stopped() @@ -306,7 +375,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { if resp.clicked() && let Some(p) = resp.interact_pointer_pos() { - match hit(p) { + match g.hit(p) { Some((i, _)) => { if ui.input(|x| x.modifiers.shift) { if !app.midi.selected.contains(&i) { @@ -317,8 +386,8 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { } // Audition on the instrument would go here (needs a live MIDI input path). } - None if s.edit.tool == Tool::Pencil => { - let pitch = pitch_at(p.y).clamp(0, 127); + None if pencil => { + let pitch = g.pitch_at(p.y).clamp(0, 127); let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks; let _ = app.run( "midi.note_add", @@ -329,6 +398,11 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { None => app.midi.selected.clear(), } } +} + +/// Keyboard: select all, move and delete notes. +fn note_keys(app: &mut SoundApp, ui: &mut Ui, g: &Roll<'_>, cid: ClipId) { + let Roll { sequence, roll, grid_ticks, .. } = *g; // Keyboard: Cmd+A select all; arrows transpose (Shift = octave) and move by the grid. if ui.rect_contains_pointer(roll) && !ui.ctx().egui_wants_keyboard_input() { let (all, up, down, left, right, shift) = ui.input(|i| { @@ -373,12 +447,18 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": app.midi.selected})); app.midi.selected.clear(); } +} + +/// The velocity lane under the roll. +fn velocity_lane(app: &mut SoundApp, ui: &mut Ui, g: &Roll<'_>, cid: ClipId, base: Color32, label_x: f32) { + let Roll { sequence, vel, .. } = *g; + let t = Tokens::current(); // Velocity lane. ui.painter().rect_filled(vel, 0.0, Color32::from_rgb(28, 28, 30)); - ui.painter().text(pos2(full.min.x + 6.0, vel.center().y), Align2::LEFT_CENTER, "Velocity", regular(10.0), t.text_dim); + ui.painter().text(pos2(label_x, vel.center().y), Align2::LEFT_CENTER, "Velocity", regular(10.0), t.text_dim); let vresp = ui.interact(vel, ui.scope_id().with(("vel", cid.0)), Sense::click_and_drag()); for (i, n) in sequence.notes.iter().enumerate() { - let x = x_of(n.start); + let x = g.x_of(n.start); let h = (vel.height() - 4.0) * f32::from(n.velocity) / 127.0; let col = if app.midi.selected.contains(&i) { Color32::WHITE } else { base }; ui.painter().line_segment([pos2(x + 1.0, vel.max.y - 2.0), pos2(x + 1.0, vel.max.y - 2.0 - h)], Stroke::new(3.0, col)); @@ -386,8 +466,8 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { } if (vresp.clicked() || vresp.dragged()) && let Some(p) = vresp.interact_pointer_pos() - && let Some((i, n)) = sequence.notes.iter().enumerate().min_by_key(|(_, n)| ((x_of(n.start) - p.x).abs() * 10.0) as i64) - && (x_of(n.start) - p.x).abs() < 8.0 + && let Some((i, n)) = sequence.notes.iter().enumerate().min_by_key(|(_, n)| ((g.x_of(n.start) - p.x).abs() * 10.0) as i64) + && (g.x_of(n.start) - p.x).abs() < 8.0 { let v = (((vel.max.y - 2.0 - p.y) / (vel.height() - 4.0)) * 127.0).round().clamp(1.0, 127.0) as i64; let _ = app.engine.execute_merged("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v}), &format!("vel:{}", cid.0)); diff --git a/crates/ui-egui/src/panels/plugins.rs b/crates/ui-egui/src/panels/plugins.rs index 7a7e575c..29125432 100644 --- a/crates/ui-egui/src/panels/plugins.rs +++ b/crates/ui-egui/src/panels/plugins.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, Sense, SoundApp, Stroke, Tokens, Ui, json, mono, pos2, vec2}; /// Draw plugin windows for instrument and insert slots that still exist, removing closed ones from the list. pub(super) fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/panels/tracks.rs b/crates/ui-egui/src/panels/tracks.rs index 41b82794..b4fd9bb8 100644 --- a/crates/ui-egui/src/panels/tracks.rs +++ b/crates/ui-egui/src/panels/tracks.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, Color32, Rect, Sense, SoundApp, Stroke, Tokens, Ui, bold, gutter_scroll, json, panel_header, pos2, regular, rgb, vec2, + with_pending_offset, +}; /// Drag a Tracks list row to reorder the tracks: a line marks the gap it will land in. fn drag_track(app: &mut SoundApp, ui: &Ui, row: Rect, id: u64, dropped: bool) { diff --git a/crates/ui-egui/src/panels/transport.rs b/crates/ui-egui/src/panels/transport.rs index 511ccb51..9451c1d6 100644 --- a/crates/ui-egui/src/panels/transport.rs +++ b/crates/ui-egui/src/panels/transport.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Rect, Sense, SoundApp, Tokens, json, mono, pos2, vec2}; pub(super) fn transport_window(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_transport; diff --git a/crates/ui-egui/src/panels/windows.rs b/crates/ui-egui/src/panels/windows.rs index a0719ea2..dd1e06d7 100644 --- a/crates/ui-egui/src/panels/windows.rs +++ b/crates/ui-egui/src/panels/windows.rs @@ -517,6 +517,8 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { number_combo(ui, "pe-rate", "Sample rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz"); ui.end_row(); ui.label("Buffer size"); + // The combo lists standard power-of-two sizes. `setup.playback_engine` does not round + // onto this list: it keeps any integer 16..=8192, the same as `ui.audio_device`. let buffers = out.map(DeviceInfo::buffer_sizes).unwrap_or_else(|| soundcraft_playback::devices::STANDARD_BUFFERS.to_vec()); number_combo(ui, "pe-buffer", "Buffer size", &mut draft.buffer_size, &buffers, "samples"); ui.end_row(); @@ -623,6 +625,53 @@ pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result Ok(out) } +/// Programmatic `setup.playback_engine` / `setup.hardware`. `device` is the output name and +/// `buffer_size` is any integer 16..=8192 or null (not rounded to the window's power-of-two list); +/// both go through [`audio_device_command`]. Delay compensation still goes through the engine +/// command, and only after the device change has been accepted. The result is the open device: +/// its name, the buffer actually in use (`device_buffer`, null for the device default), its sample +/// rate, and delay compensation. `applied` is true only when an audio device is open. +pub fn setup_audio_command(app: &mut SoundApp, id: &str, p: &serde_json::Value) -> Result { + use serde_json::Value; + let mut audio = serde_json::Map::new(); + if let Some(device) = p.get("device") { + if !device.is_null() && !device.is_string() { + return Err("`device` must be a string or null".into()); + } + audio.insert("output".to_string(), device.clone()); + } + if let Some(buffer) = p.get("buffer_size") { + audio.insert("buffer_size".to_string(), buffer.clone()); + } + if !audio.is_empty() { + audio_device_command(app, &Value::Object(audio))?; + } + if id == "setup.playback_engine" + && let Some(on) = p.get("delay_compensation").and_then(Value::as_bool) + { + app.engine.execute("setup.playback_engine", &json!({"delay_compensation": on})).map_err(|e| e.to_string())?; + } + let applied = app.player.as_ref().is_some_and(|player| !player.silent); + let (device, buffer_size, sample_rate) = match app.player.as_ref() { + Some(player) => { + let buffer = match player.device_buffer { + Some(n) => json!(n), + None => Value::Null, + }; + (json!(player.device_name), buffer, json!(player.device_rate)) + } + None => (Value::Null, Value::Null, json!(app.engine.session().sample_rate.hz())), + }; + Ok(json!({ + "applied": applied, + "device": device, + "buffer_size": buffer_size, + "sample_rate": sample_rate, + "bit_depth": app.engine.session().bit_depth, + "delay_compensation": app.engine.session().edit.delay_compensation, + })) +} + /// Setup › I/O: busses and output paths. pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_io_setup; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 35319f1c..e2c9a979 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -177,6 +177,14 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run("edit.tool", json!({"tool": tool})); true } + _ => fixed_more(app, key, m, mac), + } +} + +/// The rest of [`fixed`]'s keys. +fn fixed_more(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { + let plain = !m.command && !m.alt && !m.ctrl && !m.shift; + match key { // `=` is what a bare press of the unlabeled +/- key reports (it has no shift held, so it // can never be `Key::Plus`); `Key::Plus` is kept for a keyboard that does send it (e.g. a // numpad +). `!m.alt` leaves Alt+= to `window.event_list`'s registered shortcut. diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index bbbba444..6d61ca95 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -62,6 +62,36 @@ fn new_session_cancels_play_queued_before_the_next_frame() { assert_eq!(app.engine.transport.position, 0); } +#[test] +fn play_from_starts_at_from_regardless_of_the_selection() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 7_632_000, "end": 7_680_000})).unwrap(); + app.run("transport.play", json!({"from": 4_560_000})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert_eq!(app.engine.transport.position, 4_560_000 + 12_000); + + app.run("transport.play", json!({"from": 96_000})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert_eq!(app.engine.transport.position, 96_000 + 12_000); +} + +#[test] +fn play_from_while_looping_drops_the_selection_loop() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("options.loop_playback", json!({"value": true})).unwrap(); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 96_000, "end": 144_000})).unwrap(); + app.run("transport.play", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + app.run("transport.play", json!({"from": 480_000})).unwrap(); + app.handle_transport(0.25); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert_eq!(app.engine.transport.position, 480_000 + 24_000); +} + #[test] fn record_with_nothing_armed_says_what_to_do() { let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); @@ -244,6 +274,67 @@ fn a_pass_and_the_fader_ride_driving_it_are_one_undo_step() { assert_eq!(app.engine.undo_history().len(), steps + 1); } +/// #1146: a send's level writes its own automation lane, by the track's mode like its fader: Touch +/// while the send is dragged, Latch from the first drag until stop, Write throughout. The track's +/// volume lane is left alone. +#[test] +fn a_dragged_send_level_writes_send_automation() { + use soundcraft_model::AutoParam; + let points = |app: &SoundApp, param: &AutoParam| app.engine.session().track_by_name("Lead").unwrap().lane(param).map_or(0, |l| l.points.len()); + let level = |app: &SoundApp| app.engine.session().track_by_name("Lead").unwrap().lane(&AutoParam::SendLevel(0)).map(|l| l.points.clone()); + let pass = |app: &mut SoundApp| { + app.handle_transport(0.1); + app.write_automation(); + }; + for mode in ["touch", "latch", "write"] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let lead = app.engine.session().track_by_name("Lead").unwrap().id; + assert!(app.engine.session().track(lead).unwrap().mixer.sends[0].is_some(), "the demo's Lead sends to the reverb"); + let volume = points(&app, &AutoParam::Volume); + app.run("mix.automation_mode", json!({"tracks": ["Lead"], "mode": mode})).unwrap(); + app.run("transport.play", json!({})).unwrap(); + for _ in 0..3 { + pass(&mut app); + } + let before_drag = points(&app, &AutoParam::SendLevel(0)); + if mode == "write" { + assert!(before_drag > 0, "Write writes the send without a drag"); + } else { + assert_eq!(before_drag, 0, "{mode}: nothing written before the send is touched"); + } + // What the send's slot and its floating window do while the level is dragged. + app.pointer_down = true; + app.gesture = Some(super::Gesture::SendFader { track: lead, slot: 0 }); + for frame in 0..4 { + pass(&mut app); + let db = -12.0 + frame as f32; + app.engine.execute_merged("mix.send_level", &json!({"track": lead.0, "slot": 0, "db": db}), &format!("send:{}:0", lead.0)).unwrap(); + } + let dragged = points(&app, &AutoParam::SendLevel(0)); + assert!(dragged >= before_drag + 3, "{mode}: the drag is written ({before_drag} -> {dragged})"); + assert!(level(&app).unwrap().iter().any(|p| (p.value - (-10.0)).abs() < 1e-6), "{mode}: the points follow the send level"); + // Released. + app.pointer_down = false; + app.gesture = None; + for _ in 0..3 { + pass(&mut app); + } + let released = points(&app, &AutoParam::SendLevel(0)); + if mode == "touch" { + assert_eq!(released, dragged, "Touch stops writing when the send is let go"); + } else { + assert!(released > dragged, "{mode}: keeps writing after the release"); + } + app.run("transport.stop", json!({})).unwrap(); + app.handle_transport(0.0); + app.write_automation(); + if mode != "write" { + assert_eq!(points(&app, &AutoParam::Volume), volume, "{mode}: the track's volume lane is left alone"); + } + assert!(app.touched_sends.is_empty(), "{mode}: stop ends the pass"); + } +} + #[test] fn plugin_states_read_during_a_drag_keep_it_one_undo_step() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); @@ -1387,6 +1478,56 @@ fn transport_simulation_advances_by_monotonic_elapsed_time_only() { app.engine.transport.playing = false; app.sim = None; } +/// One host iteration: the raw-input hook, then logic and UI in a single pass. +fn run_host_frame(app: &mut SoundApp, ctx: &egui::Context, mut input: egui::RawInput) { + app.raw_input_hook(ctx, &mut input); + let mut output = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + output.textures_delta.clear(); +} + +/// Run one frame that discards its first layout pass; returns how many passes ran. +fn run_host_frame_discarding_first_pass(app: &mut SoundApp, ctx: &egui::Context, input: egui::RawInput) -> usize { + let mut passes = 0; + let mut output = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + passes += 1; + if passes == 1 { + ui.ctx().request_discard("plugin gesture must survive a second layout pass"); + } + app.ui(ui); + }); + output.textures_delta.clear(); + passes +} + +/// The editor-poll fixture: each poll counts itself, takes the queued edits and, for the gestures that +/// ended, captures the instrument state `latest` holds. +fn poll_fixture( + reports: &std::rc::Rc>>, + latest: &std::rc::Rc>, + polls: &std::rc::Rc>, + captures: &std::rc::Rc>>, +) -> super::EditorPollFixture { + let (poll_reports, poll_latest, poll_count, poll_captures) = (reports.clone(), latest.clone(), polls.clone(), captures.clone()); + Box::new(move |pending| { + poll_count.set(poll_count.get() + 1); + let edits = std::mem::take(&mut *poll_reports.borrow_mut()); + super::editor_changes( + pending, + edits, + Vec::new(), + |_| false, + |due| { + poll_captures.borrow_mut().extend_from_slice(due); + due.iter().map(|&(track, slot)| (track, slot, vec![poll_latest.get(), 1])).collect() + }, + ) + }) +} + /// Plugin queues are drained once per host iteration, rather than once per discarded pass. /// The fixture replaces only native polling; the real raw hook, logic/UI, scheduler and /// engine state/undo commands run normally. @@ -1400,13 +1541,7 @@ fn editor_gesture_polling_survives_discarded_and_hidden_host_iterations() { let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; for _ in 0..4 { - let mut input = raw(); - app.raw_input_hook(&ctx, &mut input); - let mut output = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - output.textures_delta.clear(); + run_host_frame(&mut app, &ctx, raw()); } let keys = app.engine.session().track_by_name("Keys").unwrap().id; let original = instrument_state(&app, keys); @@ -1421,34 +1556,11 @@ fn editor_gesture_polling_survives_discarded_and_hidden_host_iterations() { let latest = Rc::new(Cell::new(1u8)); let polls = Rc::new(Cell::new(0)); let captures = Rc::new(RefCell::new(Vec::new())); - let (poll_reports, poll_latest, poll_count, poll_captures) = (reports.clone(), latest.clone(), polls.clone(), captures.clone()); - app.editor_poll_fixture = Some(Box::new(move |pending| { - poll_count.set(poll_count.get() + 1); - let edits = std::mem::take(&mut *poll_reports.borrow_mut()); - super::editor_changes( - pending, - edits, - Vec::new(), - |_| false, - |due| { - poll_captures.borrow_mut().extend_from_slice(due); - due.iter().map(|&(track, slot)| (track, slot, vec![poll_latest.get(), 1])).collect() - }, - ) - })); + app.editor_poll_fixture = Some(poll_fixture(&reports, &latest, &polls, &captures)); reports.borrow_mut().push((keys, soundcraft_mix::INSTRUMENT_SLOT, "cutoff".into(), 1.0)); let mut input = raw(); app.raw_input_hook(&ctx, &mut input); - let mut passes = 0; - let mut output = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - passes += 1; - if passes == 1 { - ui.ctx().request_discard("plugin gesture must survive a second layout pass"); - } - app.ui(ui); - }); - output.textures_delta.clear(); + let passes = run_host_frame_discarding_first_pass(&mut app, &ctx, input); assert!(passes >= 2, "the regression must actually force a discarded pass"); assert_eq!(polls.get(), 1, "the rerun cannot drain an empty editor queue"); assert!(captures.borrow().is_empty(), "the ongoing gesture has not ended"); @@ -1490,8 +1602,19 @@ fn editor_gesture_polling_survives_discarded_and_hidden_host_iterations() { let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); assert_eq!(polls.get(), 4, "a fresh empty host iteration is still live"); assert_eq!(captures.borrow().len(), 1, "the completed gesture is captured once"); - assert_eq!(app.engine.revision, settled_revision); + assert_history_unchanged(&app, settled_revision, undo, &history, &journal); +} + +/// The engine's revision, undo history and journal are as they were. +fn assert_history_unchanged( + app: &SoundApp, + revision: u64, + undo: usize, + history: &[String], + journal: &std::collections::VecDeque<(String, serde_json::Value)>, +) { + assert_eq!(app.engine.revision, revision); assert_eq!(app.engine.undo_history().len(), undo); assert_eq!(app.engine.undo_history(), history); - assert_eq!(app.engine.journal, journal); + assert_eq!(&app.engine.journal, journal); } diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index fcc52541..8fee933a 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -2,6 +2,8 @@ use crate::SoundApp; +#[cfg(not(target_arch = "wasm32"))] +use craft_update::dialog::{CheckResultDialog, CheckResultResponse}; #[cfg(not(target_arch = "wasm32"))] use craft_update::updater::{Action, Status, Updater}; @@ -13,21 +15,24 @@ pub(crate) struct Runtime { startup_check: bool, #[cfg(not(target_arch = "wasm32"))] check_requested: bool, - pub(crate) manual_window_open: bool, - pub(crate) status_message: Option, + /// The check-result dialog (native) or the web build's notice is showing. + manual_window_open: bool, + #[cfg(not(target_arch = "wasm32"))] + result_dialog: CheckResultDialog, } impl Default for Runtime { fn default() -> Self { Self { #[cfg(not(target_arch = "wasm32"))] - updater: Updater::new(&craft_update::suite::SOUNDCRAFT, env!("CARGO_PKG_VERSION")), + updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")), #[cfg(not(target_arch = "wasm32"))] startup_check: false, #[cfg(not(target_arch = "wasm32"))] check_requested: false, manual_window_open: false, - status_message: None, + #[cfg(not(target_arch = "wasm32"))] + result_dialog: CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")), } } } @@ -48,7 +53,6 @@ pub(crate) fn check(app: &mut SoundApp) { #[cfg(target_arch = "wasm32")] { app.updates_rt.manual_window_open = true; - app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into()); } } @@ -58,6 +62,7 @@ pub(crate) fn tick(app: &mut SoundApp, ctx: &egui::Context) { let rt = &mut app.updates_rt; if std::mem::take(&mut rt.check_requested) { rt.updater.check_now(ctx); + rt.manual_window_open = true; } else if rt.startup_check { rt.updater.check_at_start(ctx); } @@ -77,46 +82,36 @@ pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) { Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::None => {} } - // The window answers Help ▸ Check for Updates, so it follows the updater's status. + // Help ▸ Check for Updates shows its outcome in the shared result dialog until the user closes it. let rt = &mut app.updates_rt; - (rt.manual_window_open, rt.status_message) = match rt.updater.status() { - Status::Idle => (false, None), - Status::Checking => (true, None), - Status::UpToDate => (true, Some(format!("SoundCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))), - Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))), - }; + if rt.manual_window_open { + match rt.result_dialog.show(ctx, rt.updater.status()) { + CheckResultResponse::Close => rt.manual_window_open = false, + CheckResultResponse::CheckAgain => rt.updater.check_now(ctx), + CheckResultResponse::None => {} + } + // A found update goes to the startup prompt, which takes over from the dialog. + if matches!(rt.updater.status(), Status::Idle) { + rt.manual_window_open = false; + } + } + // Closing the dialog dismisses the outcome it showed. + if !rt.manual_window_open { + rt.updater.dismiss_status(); + } } + #[cfg(target_arch = "wasm32")] if app.updates_rt.manual_window_open { let mut open = true; - let mut close = false; egui::Window::new("Check for Updates") .open(&mut open) .resizable(false) .collapsible(false) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) .show(ctx, |ui| { - if let Some(msg) = &app.updates_rt.status_message { - ui.label(msg); - } else { - ui.label("Checking for updates…"); - } - ui.add_space(8.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Close").clicked() { - close = true; - } - }); + ui.label("Update check is not supported in the web browser."); }); - if !open || close { - app.updates_rt.manual_window_open = false; - app.updates_rt.status_message = None; - } - } - - // Closing the window dismisses the outcome it showed. - #[cfg(not(target_arch = "wasm32"))] - if !app.updates_rt.manual_window_open { - app.updates_rt.updater.dismiss_status(); + app.updates_rt.manual_window_open = open; } } diff --git a/crates/ui-egui/tests/it/audio_device.rs b/crates/ui-egui/tests/it/audio_device.rs index 6095a723..7294b3e9 100644 --- a/crates/ui-egui/tests/it/audio_device.rs +++ b/crates/ui-egui/tests/it/audio_device.rs @@ -25,3 +25,79 @@ fn the_audio_device_choice_is_kept_only_when_it_is_applied() { assert_eq!(r["config"]["buffer_size"], 256); assert!(r["device"].is_object(), "{r}"); } + +fn new_app() -> SoundApp { + SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()) +} + +#[test] +fn playback_engine_refuses_an_invalid_or_untimely_device_choice_without_side_effects() { + let mut app = new_app(); + let before = app.ui.audio.clone(); + let bit_depth = app.engine.session().bit_depth; + assert!(!app.ui.show_playback_engine); + + let r = app.run("setup.hardware", json!({})).unwrap(); + assert_eq!(r["applied"], false, "without a player there is no open device"); + assert_eq!(r["bit_depth"], json!(bit_depth)); + + assert!(app.engine.session().edit.delay_compensation, "delay compensation starts on"); + let err = app.run("setup.playback_engine", json!({"buffer_size": 8, "delay_compensation": false})).unwrap_err(); + assert!(err.contains("16..=8192"), "{err}"); + assert_eq!(app.ui.audio, before, "an invalid buffer changes nothing"); + assert!(app.engine.session().edit.delay_compensation, "an invalid buffer does not apply delay compensation"); + + app.engine.transport.recording = true; + let err = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap_err(); + assert!(err.contains("stop recording"), "{err}"); + assert_eq!(app.ui.audio, before); + assert!(app.engine.session().edit.delay_compensation, "a refused device change does not apply delay compensation"); + app.engine.transport.recording = false; + + let err = app.run("setup.playback_engine", json!({"device": 42, "delay_compensation": false})).unwrap_err(); + assert!(err.contains("`device` must be a string or null"), "{err}"); + assert_eq!(app.ui.audio, before, "an invalid device changes nothing"); + assert!(app.engine.session().edit.delay_compensation, "an invalid device does not apply delay compensation"); +} + +#[test] +fn playback_engine_applies_the_device_choice_and_reports_the_open_device() { + let mut app = new_app(); + let bit_depth = app.engine.session().bit_depth; + let r = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap(); + assert_eq!(app.ui.audio.buffer_size, Some(256)); + assert!(!app.engine.session().edit.delay_compensation); + assert!(!app.ui.show_playback_engine, "a programmatic call does not open the window"); + assert_eq!(r["delay_compensation"], false); + let player = app.player.as_ref().unwrap(); + assert_eq!(r["applied"], !player.silent); + assert_eq!(r["device"], json!(player.device_name)); + assert_eq!(r["buffer_size"], json!(player.device_buffer)); + assert_eq!(r["sample_rate"], player.device_rate); + assert_eq!(r["bit_depth"], json!(bit_depth)); +} + +#[test] +fn playback_engine_applies_the_same_device_choice_as_the_audio_command() { + let mut app = new_app(); + app.run("setup.playback_engine", json!({"delay_compensation": false})).unwrap(); + + // 300 is in range and is not rounded onto the window's power-of-two list. + let mut via_window_command = new_app(); + via_window_command.run("ui.audio_device", json!({"buffer_size": 300, "output": "Elsewhere"})).unwrap(); + app.run("setup.hardware", json!({"buffer_size": 300, "device": "Elsewhere"})).unwrap(); + assert_eq!(app.ui.audio.buffer_size, Some(300)); + assert_eq!(app.ui.audio.buffer_size, via_window_command.ui.audio.buffer_size); + assert_eq!(app.ui.audio.output, via_window_command.ui.audio.output); + assert!(!app.ui.show_playback_engine); + + app.run("setup.playback_engine", json!({"buffer_size": null, "device": null})).unwrap(); + assert_eq!(app.ui.audio.buffer_size, None); + assert_eq!(app.ui.audio.output, None); + assert!(!app.engine.session().edit.delay_compensation, "clearing the device leaves delay compensation"); + + let cfg = app.ui.audio.clone(); + soundcraft_ui_egui::menus::invoke_menu(&mut app, "setup.playback_engine", "Setup > Playback Engine..."); + assert!(app.ui.show_playback_engine, "the menu still opens the Playback Engine window"); + assert_eq!(app.ui.audio, cfg, "opening the window does not change the device"); +} diff --git a/crates/ui-egui/tests/it/clip_menu.rs b/crates/ui-egui/tests/it/clip_menu.rs index 8da61fd7..bfac15b6 100644 --- a/crates/ui-egui/tests/it/clip_menu.rs +++ b/crates/ui-egui/tests/it/clip_menu.rs @@ -1,6 +1,7 @@ //! Regression: the clip context menu must stay open while the pointer moves onto it, so its //! Delete item can be used. It used to vanish once the pointer left the lane, because the lane -//! returned early (no hover position) before the menu was drawn. +//! returned early (no hover position) before the menu was drawn. A right-click on a clip of the +//! edit selection acts on the whole selection (#1191). use egui::{Event, Modifiers, PointerButton, pos2, vec2}; use egui_kittest::Harness; @@ -24,15 +25,16 @@ fn press(h: &mut Harness<'_, SoundApp>, x: f32, y: f32, button: PointerButton) { s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); } -fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize { +fn clip_count(h: &Harness<'_, SoundApp>, track: &str) -> usize { let s = h.state().engine.session(); - s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len()) + s.tracks.iter().find(|t| t.name == track).map_or(0, |t| t.clips().len()) } -/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete -/// removes one Kick clip. -#[test] -fn clip_menu_stays_open_while_moving_onto_delete() { +fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize { + clip_count(h, "Kick") +} + +fn demo() -> Harness<'static, SoundApp> { let engine = soundcraft_engine::demo::demo_engine(); let app = SoundApp::new(engine, None, Services::default()); let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state( @@ -46,26 +48,123 @@ fn clip_menu_stays_open_while_moving_onto_delete() { for _ in 0..6 { step(&mut h); } + h +} + +/// Selects the whole session on `tracks`, as a drag across their clips does. +fn select_all_of(h: &mut Harness<'_, SoundApp>, tracks: &[&str]) { + h.state_mut().engine.execute("edit.select", &serde_json::json!({"tracks": tracks, "start": 0, "end": 48_000 * 600})).unwrap(); +} + +/// Right-clicks the Kick clip and clicks `item` in its menu, moving onto the item first as a real pointer would. +fn kick_menu(h: &mut Harness<'_, SoundApp>, item: &str) { + press(h, 880.0, 240.0, PointerButton::Secondary); + for _ in 0..10 { + step(h); + } + // Ask the newly laid-out menu where the item is instead of pinning coordinates that change with egui's item metrics. + let at = h.get_by_label(item).rect().center(); + h.state_mut().synthetic.push(Event::PointerMoved(at)); + for _ in 0..10 { + step(h); + } + press(h, at.x, at.y, PointerButton::Primary); + for _ in 0..15 { + step(h); + } +} + +/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete +/// removes one Kick clip. +#[test] +fn clip_menu_stays_open_while_moving_onto_delete() { + let mut h = demo(); let before = kick_clip_count(&h); assert!(before > 0, "the demo session should have a recorded Kick clip"); - // Right-click the Kick clip, then move the pointer into the menu and down to Delete. - press(&mut h, 880.0, 240.0, PointerButton::Secondary); - for _ in 0..10 { - step(&mut h); - } - // Move (no click) onto the menu's Delete item, as a real pointer would, so the lane loses - // its hover. Ask the newly laid-out menu where the item is instead of pinning coordinates - // that change with egui's item metrics. - let delete = h.get_by_label("Delete").rect().center(); - h.state_mut().synthetic.push(Event::PointerMoved(delete)); - for _ in 0..10 { - step(&mut h); - } - press(&mut h, delete.x, delete.y, PointerButton::Primary); - for _ in 0..15 { - step(&mut h); - } + kick_menu(&mut h, "Delete"); assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip"); } + +/// Delete on a clip of a drag selection clears the whole selection, as Edit > Clear does, in one undo step (#1191). +#[test] +fn delete_on_a_selected_clip_clears_the_whole_selection() { + let mut h = demo(); + let (kick, snare) = (clip_count(&h, "Kick"), clip_count(&h, "Snare")); + assert!(snare > 1, "the demo session should have several Snare clips"); + select_all_of(&mut h, &["Kick", "Snare"]); + + kick_menu(&mut h, "Delete"); + + assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (0, 0), "every selected clip should be cleared"); + h.state_mut().engine.execute("edit.undo", &serde_json::json!({})).unwrap(); + assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (kick, snare), "one undo should bring them all back"); +} + +/// A clip outside the selection is still deleted on its own, and the selection's clips stay. +#[test] +fn delete_on_a_clip_outside_the_selection_removes_only_that_clip() { + let mut h = demo(); + let (kick, snare) = (clip_count(&h, "Kick"), clip_count(&h, "Snare")); + select_all_of(&mut h, &["Snare"]); + + kick_menu(&mut h, "Delete"); + + assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (kick - 1, snare)); +} + +/// With clips selected one by one (Grabber shift-click), Delete removes exactly those clips, not the other clips +/// their span covers. +#[test] +fn delete_on_selected_clips_keeps_the_unselected_clips_between_them() { + let mut h = demo(); + let ids = |h: &Harness<'_, SoundApp>, track: &str| -> Vec { + 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 = 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 = 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 = { + 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"); +} diff --git a/crates/ui-egui/tests/it/command_reachability.rs b/crates/ui-egui/tests/it/command_reachability.rs index 797f1d86..a1c0e81a 100644 --- a/crates/ui-egui/tests/it/command_reachability.rs +++ b/crates/ui-egui/tests/it/command_reachability.rs @@ -25,8 +25,6 @@ const KNOWN_UNREACHED: &[&str] = &[ "mix.solo_safe", "options.post_roll", "options.pre_roll", - "setup.hardware", // menu opens the Playback Engine window, which doesn't run it - "setup.playback_engine", // same "setup.reset_edit_state", "track.load_preset", "track.pin", @@ -93,6 +91,13 @@ fn every_command_has_a_ui_path() { assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}"); } +#[test] +fn playback_engine_menu_opens_the_window_instead_of_running_the_command() { + let extra = menus::ui_aliases(); + assert_eq!(catalog::implemented_by("Setup > Playback Engine...", &extra).as_deref(), Some("window.playback_engine")); + assert_eq!(catalog::implemented_by("Setup > Hardware...", &extra).as_deref(), Some("window.playback_engine")); +} + #[test] fn new_midi_clip_is_in_the_clip_menu() { let extra = menus::ui_aliases(); diff --git a/crates/ui-egui/tests/it/strip_silence_rectangles.rs b/crates/ui-egui/tests/it/strip_silence_rectangles.rs index 4ee12174..a76445ec 100644 --- a/crates/ui-egui/tests/it/strip_silence_rectangles.rs +++ b/crates/ui-egui/tests/it/strip_silence_rectangles.rs @@ -109,3 +109,18 @@ fn the_rectangles_stay_while_rename_is_open_and_go_when_the_window_closes() { assert_eq!(rectangles(&h), 0); assert_eq!(clips(&h, track), before); } + +#[test] +fn closing_the_window_while_the_mix_window_shows_stops_the_scan() { + let (mut h, _) = strip_silence_dialog(); + if let Some(Dialog::StripSilence { threshold, .. }) = &mut h.state_mut().dialogs.open { + *threshold = -30.0; + } + h.run_steps(1); + assert!(h.state().dialogs.strip_scan_running(), "a scan for the new threshold started"); + h.state_mut().run("window.mix", json!({})).unwrap(); + h.run_steps(2); + h.state_mut().dialogs.open = None; + h.run_steps(2); + assert!(!h.state().dialogs.strip_scan_running(), "the scan still runs after the window closed"); +} diff --git a/crates/ui-egui/tests/it/zoom_toggle.rs b/crates/ui-egui/tests/it/zoom_toggle.rs index d3badcff..2011c812 100644 --- a/crates/ui-egui/tests/it/zoom_toggle.rs +++ b/crates/ui-egui/tests/it/zoom_toggle.rs @@ -1,5 +1,5 @@ use egui::os::OperatingSystem; -use egui::{Key, vec2}; +use egui::{Key, Modifiers, vec2}; use egui_kittest::{Harness, kittest::Queryable}; use serde_json::json; 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:?}"); } } + +#[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}"); + } + } +} diff --git a/crates/ui-egui/tests/mix_instrument_contrast.rs b/crates/ui-egui/tests/mix_instrument_contrast.rs index bcdc5c28..2bb6cd92 100644 --- a/crates/ui-egui/tests/mix_instrument_contrast.rs +++ b/crates/ui-egui/tests/mix_instrument_contrast.rs @@ -72,6 +72,19 @@ fn contrast(a: Color32, b: Color32) -> f64 { (a.max(b) + 0.05) / (a.min(b) + 0.05) } +/// A primary click at `at`, one frame for the press and one for the release. +fn press_and_release(harness: &mut egui_kittest::Harness<'_, SoundApp>, at: egui::Pos2) { + for pressed in [true, false] { + harness.input_mut().events.push(Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }); + harness.step(); + } +} + /// In real frames, populated and empty instrument and insert slots use the active theme's readable palette across /// light, dark and system modes, and a plugin change leaves the rest of the session and audio pool intact. #[test] @@ -169,15 +182,7 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() { // Exercise the same painted control's ordinary click/picker path after palette changes. let at = slot(harness.output(), "SubSynth").rect.center(); - for pressed in [true, false] { - harness.input_mut().events.push(Event::PointerButton { - pos: at, - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }); - harness.step(); - } + press_and_release(&mut harness, at); harness.step(); // Let the popup finish its initial sizing pass. harness.get_by_label("Built-in").hover(); harness.step(); diff --git a/crates/video/src/h264/deblock.rs b/crates/video/src/h264/deblock.rs index 12ba8da5..e918476c 100644 --- a/crates/video/src/h264/deblock.rs +++ b/crates/video/src/h264/deblock.rs @@ -279,7 +279,6 @@ fn filter_horizontal(pix: &mut [u8], stride: usize, x: usize, y: } /// Deblock one macroblock (macroblocks must be processed in raster order). -#[allow(clippy::needless_range_loop)] pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { let q = &pic.mbs[addr]; if q.slice_num == u32::MAX { @@ -303,6 +302,23 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { let alpha_off = sq.alpha_offset; let beta_off = sq.beta_offset; let t8 = q.transform_8x8; + let bs = boundary_strengths(pic, q, sq, left, top, t8); + let qp_of = |st: &MbState| if st.kind == MbKind::IPcm { 0 } else { st.qp as i32 }; + let q_qp = qp_of(q); + let q_qpc = q.qpc; + let p_qp = [left.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc)), top.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc))]; + let params = |qpp: i32, qpq: i32, b: [u8; 4]| { + let qpav = (qpp + qpq + 1) >> 1; + let index_a = (qpav + alpha_off).clamp(0, 51) as usize; + let index_b = (qpav + beta_off).clamp(0, 51) as usize; + let tc = |b: u8| if (1..4).contains(&b) { TC0[index_a][b as usize - 1] as i32 } else { 0 }; + EdgeParams { bs: b, tc0: [tc(b[0]), tc(b[1]), tc(b[2]), tc(b[3])], alpha: ALPHA[index_a] as i32, beta: BETA[index_b] as i32 } + }; + filter_luma(pic, (mx, my), &bs, q_qp, p_qp, ¶ms); + filter_chroma(pic, (mx, my), &bs, q_qpc, p_qp, ¶ms); +} + +fn boundary_strengths(pic: &PicState, q: &MbState, sq: &SliceInfo, left: Option, top: Option, t8: bool) -> [[[u8; 4]; 4]; 2] { // bS[dir][edge][segment]; dir 0 = vertical edges (x), 1 = horizontal edges (y) let mut bs = [[[0u8; 4]; 4]; 2]; if q.kind.is_intra() { @@ -356,20 +372,18 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { } } } - let qp_of = |st: &MbState| if st.kind == MbKind::IPcm { 0 } else { st.qp as i32 }; - let q_qp = qp_of(q); - let q_qpc = q.qpc; - let p_qp = [left.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc)), top.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc))]; - let params = |qpp: i32, qpq: i32, b: [u8; 4]| { - let qpav = (qpp + qpq + 1) >> 1; - let index_a = (qpav + alpha_off).clamp(0, 51) as usize; - let index_b = (qpav + beta_off).clamp(0, 51) as usize; - let tc = |b: u8| if (1..4).contains(&b) { TC0[index_a][b as usize - 1] as i32 } else { 0 }; - EdgeParams { bs: b, tc0: [tc(b[0]), tc(b[1]), tc(b[2]), tc(b[3])], alpha: ALPHA[index_a] as i32, beta: BETA[index_b] as i32 } - }; + bs +} + +fn filter_luma( + pic: &mut PicState, + (mx, my): (usize, usize), + bs: &[[[u8; 4]; 4]; 2], + q_qp: i32, + p_qp: [Option<(i32, [u8; 2])>; 2], + params: &impl Fn(i32, i32, [u8; 4]) -> EdgeParams, +) { let width = pic.planes.width; - let cwidth = pic.planes.cwidth; - // luma for dir in 0..2 { for e in 0..4 { if bs[dir][e] == [0; 4] { @@ -389,6 +403,17 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { } } } +} + +fn filter_chroma( + pic: &mut PicState, + (mx, my): (usize, usize), + bs: &[[[u8; 4]; 4]; 2], + q_qpc: [u8; 2], + p_qp: [Option<(i32, [u8; 2])>; 2], + params: &impl Fn(i32, i32, [u8; 4]) -> EdgeParams, +) { + let cwidth = pic.planes.cwidth; // chroma (4:2:0): edges at chroma samples 0 and 4 use the bS of luma edges 0 and 2 for c in 0..2 { for dir in 0..2 { diff --git a/crates/video/src/h264/dpb.rs b/crates/video/src/h264/dpb.rs index 5afc169b..34688e2f 100644 --- a/crates/video/src/h264/dpb.rs +++ b/crates/video/src/h264/dpb.rs @@ -198,6 +198,7 @@ impl Dpb { let mut ref_idx = 0usize; for m in &sh.ref_pic_list_mod[l] { 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 let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc { 0 | 1 => { @@ -262,9 +263,9 @@ impl Dpb { for m in mmcos { match m.op { 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 { - 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; } } @@ -277,14 +278,14 @@ impl Dpb { } } 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 { if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx { e.mark = RefMark::Unused; } } 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.long_term_frame_idx = m.long_term_frame_idx; } @@ -418,3 +419,67 @@ impl Default for Dpb { 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()); + } + } + } +} diff --git a/crates/video/src/h264/intra.rs b/crates/video/src/h264/intra.rs index decf2965..176d9a11 100644 --- a/crates/video/src/h264/intra.rs +++ b/crates/video/src/h264/intra.rs @@ -61,125 +61,146 @@ fn load_edge(plane: &[u8], stride: usize, x: usize, y: usize, n: usize, a: Avail /// Directional / DC prediction shared by Intra_4x4 and Intra_8x8 (8.3.1.2.x, 8.3.2.2.x). fn pred_nxn(mode: u8, e: &Edge, n: i32, a: Avail, out: &mut [u8], stride: usize) { - let mut put = |x: i32, y: i32, v: i32| out[y as usize * stride + x as usize] = v as u8; match mode { - 0 => { - for y in 0..n { - for x in 0..n { - put(x, y, e.t(x)); - } - } + 0 => pred_vertical(e, n, out, stride), + 1 => pred_horizontal(e, n, out, stride), + 2 => pred_dc(e, n, a, out, stride), + 3 => pred_down_left(e, n, out, stride), + 4 => pred_down_right(e, n, out, stride), + 5 => pred_vertical_right(e, n, out, stride), + 6 => pred_horizontal_down(e, n, out, stride), + 7 => pred_vertical_left(e, n, out, stride), + _ => pred_horizontal_up(e, n, out, stride), + } +} + +fn put(out: &mut [u8], stride: usize, x: i32, y: i32, value: i32) { + out[y as usize * stride + x as usize] = value as u8; +} + +fn pred_vertical(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + put(out, stride, x, y, e.t(x)); } - 1 => { - for y in 0..n { - for x in 0..n { - put(x, y, e.l(y)); - } - } + } +} + +fn pred_horizontal(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + put(out, stride, x, y, e.l(y)); } - 2 => { - let shift = if n == 4 { 2 } else { 3 }; - let st: i32 = (0..n).map(|x| e.t(x)).sum(); - let sl: i32 = (0..n).map(|y| e.l(y)).sum(); - let dc = match (a.top, a.left) { - (true, true) => (st + sl + n) >> (shift + 1), - (false, true) => (sl + (n >> 1)) >> shift, - (true, false) => (st + (n >> 1)) >> shift, - _ => 128, + } +} + +fn pred_dc(e: &Edge, n: i32, a: Avail, out: &mut [u8], stride: usize) { + let shift = if n == 4 { 2 } else { 3 }; + let st: i32 = (0..n).map(|x| e.t(x)).sum(); + let sl: i32 = (0..n).map(|y| e.l(y)).sum(); + let dc = match (a.top, a.left) { + (true, true) => (st + sl + n) >> (shift + 1), + (false, true) => (sl + (n >> 1)) >> shift, + (true, false) => (st + (n >> 1)) >> shift, + _ => 128, + }; + for y in 0..n { + for x in 0..n { + put(out, stride, x, y, dc); + } + } +} + +fn pred_down_left(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + let value = if x == n - 1 && y == n - 1 { + (e.t(2 * n - 2) + 3 * e.t(2 * n - 1) + 2) >> 2 + } else { + (e.t(x + y) + 2 * e.t(x + y + 1) + e.t(x + y + 2) + 2) >> 2 }; - for y in 0..n { - for x in 0..n { - put(x, y, dc); - } - } + put(out, stride, x, y, value); } - 3 => { - for y in 0..n { - for x in 0..n { - let v = if x == n - 1 && y == n - 1 { - (e.t(2 * n - 2) + 3 * e.t(2 * n - 1) + 2) >> 2 - } else { - (e.t(x + y) + 2 * e.t(x + y + 1) + e.t(x + y + 2) + 2) >> 2 - }; - put(x, y, v); - } - } + } +} + +fn pred_down_right(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + let value = if x > y { + (e.t(x - y - 2) + 2 * e.t(x - y - 1) + e.t(x - y) + 2) >> 2 + } else if x < y { + (e.l(y - x - 2) + 2 * e.l(y - x - 1) + e.l(y - x) + 2) >> 2 + } else { + (e.t(0) + 2 * e.tl + e.l(0) + 2) >> 2 + }; + put(out, stride, x, y, value); } - 4 => { - for y in 0..n { - for x in 0..n { - let v = if x > y { - (e.t(x - y - 2) + 2 * e.t(x - y - 1) + e.t(x - y) + 2) >> 2 - } else if x < y { - (e.l(y - x - 2) + 2 * e.l(y - x - 1) + e.l(y - x) + 2) >> 2 - } else { - (e.t(0) + 2 * e.tl + e.l(0) + 2) >> 2 - }; - put(x, y, v); - } - } + } +} + +fn pred_vertical_right(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + let z = 2 * x - y; + let value = if z >= 0 && z % 2 == 0 { + (e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 1) >> 1 + } else if z > 0 { + (e.t(x - (y >> 1) - 2) + 2 * e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 2) >> 2 + } else if z == -1 { + (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 + } else { + (e.l(y - 2 * x - 1) + 2 * e.l(y - 2 * x - 2) + e.l(y - 2 * x - 3) + 2) >> 2 + }; + put(out, stride, x, y, value); } - 5 => { - for y in 0..n { - for x in 0..n { - let z = 2 * x - y; - let v = if z >= 0 && z % 2 == 0 { - (e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 1) >> 1 - } else if z > 0 { - (e.t(x - (y >> 1) - 2) + 2 * e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 2) >> 2 - } else if z == -1 { - (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 - } else { - (e.l(y - 2 * x - 1) + 2 * e.l(y - 2 * x - 2) + e.l(y - 2 * x - 3) + 2) >> 2 - }; - put(x, y, v); - } - } + } +} + +fn pred_horizontal_down(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + let z = 2 * y - x; + let value = if z >= 0 && z % 2 == 0 { + (e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 1) >> 1 + } else if z > 0 { + (e.l(y - (x >> 1) - 2) + 2 * e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 2) >> 2 + } else if z == -1 { + (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 + } else { + (e.t(x - 2 * y - 1) + 2 * e.t(x - 2 * y - 2) + e.t(x - 2 * y - 3) + 2) >> 2 + }; + put(out, stride, x, y, value); } - 6 => { - for y in 0..n { - for x in 0..n { - let z = 2 * y - x; - let v = if z >= 0 && z % 2 == 0 { - (e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 1) >> 1 - } else if z > 0 { - (e.l(y - (x >> 1) - 2) + 2 * e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 2) >> 2 - } else if z == -1 { - (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 - } else { - (e.t(x - 2 * y - 1) + 2 * e.t(x - 2 * y - 2) + e.t(x - 2 * y - 3) + 2) >> 2 - }; - put(x, y, v); - } - } + } +} + +fn pred_vertical_left(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + let i = x + (y >> 1); + let value = if y % 2 == 0 { (e.t(i) + e.t(i + 1) + 1) >> 1 } else { (e.t(i) + 2 * e.t(i + 1) + e.t(i + 2) + 2) >> 2 }; + put(out, stride, x, y, value); } - 7 => { - for y in 0..n { - for x in 0..n { - let i = x + (y >> 1); - let v = if y % 2 == 0 { (e.t(i) + e.t(i + 1) + 1) >> 1 } else { (e.t(i) + 2 * e.t(i + 1) + e.t(i + 2) + 2) >> 2 }; - put(x, y, v); - } - } - } - _ => { - let zmax = 2 * n - 3; - for y in 0..n { - for x in 0..n { - let z = x + 2 * y; - let i = y + (x >> 1); - let v = if z < zmax && z % 2 == 0 { - (e.l(i) + e.l(i + 1) + 1) >> 1 - } else if z < zmax { - (e.l(i) + 2 * e.l(i + 1) + e.l(i + 2) + 2) >> 2 - } else if z == zmax { - (e.l(n - 2) + 3 * e.l(n - 1) + 2) >> 2 - } else { - e.l(n - 1) - }; - put(x, y, v); - } - } + } +} + +fn pred_horizontal_up(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + let zmax = 2 * n - 3; + for y in 0..n { + for x in 0..n { + let z = x + 2 * y; + let i = y + (x >> 1); + let value = if z < zmax && z % 2 == 0 { + (e.l(i) + e.l(i + 1) + 1) >> 1 + } else if z < zmax { + (e.l(i) + 2 * e.l(i + 1) + e.l(i + 2) + 2) >> 2 + } else if z == zmax { + (e.l(n - 2) + 3 * e.l(n - 1) + 2) >> 2 + } else { + e.l(n - 1) + }; + put(out, stride, x, y, value); } } } diff --git a/crates/video/src/h264/params.rs b/crates/video/src/h264/params.rs index 0970a106..d902efe3 100644 --- a/crates/video/src/h264/params.rs +++ b/crates/video/src/h264/params.rs @@ -909,4 +909,63 @@ mod tests { fn huge_pic_init_qp_is_an_error() { assert_eq!(invalid_pps(pps(None, 0, i32::MAX)), "pic_init_qp_minus26 out of range"); } + + #[test] + fn poc_extreme_offsets_do_not_overflow() { + use crate::h264::slice::{PocState, SliceHeader, SliceType}; + let mut s = sps(SpsFields::default()).unwrap(); + s.log2_max_frame_num = 16; + s.offset_for_ref_frame = vec![i32::MAX, i32::MAX]; + s.offset_for_non_ref_pic = i32::MAX; + s.offset_for_top_to_bottom_field = i32::MAX; + // Drive FrameNumOffset up to the i32 limit by wrapping frame_num repeatedly. + let mut state = PocState::default(); + for _ in 0..32_767 { + state.update_gap_frame(5, &s); + state.update_gap_frame(1, &s); + } + // prev_frame_num > frame_num below, so the next picture adds max_frame_num once more. + state.update_gap_frame(5, &s); + for (poc_type, nal_ref_idc, st) in [1u32, 2].into_iter().flat_map(|t| [1u8, 0].into_iter().flat_map(move |r| [(t, r, false), (t, r, true)])) { + s.pic_order_cnt_type = poc_type; + let state = if st { state.clone() } else { PocState::default() }; + let sh = SliceHeader { + nal_ref_idc, + 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: 2, + 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: [i32::MAX, i32::MAX], + redundant_pic_cnt: 0, + direct_spatial_mv_pred: false, + num_ref_idx_active: [1, 0], + ref_pic_list_mod: [Vec::new(), 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, + }; + // Must not panic; the value itself is meaningless for such a stream. + let _ = state.compute(&sh, &s); + } + } } diff --git a/crates/video/src/h264/slice.rs b/crates/video/src/h264/slice.rs index bd641d5a..5fb387aa 100644 --- a/crates/video/src/h264/slice.rs +++ b/crates/video/src/h264/slice.rs @@ -1,7 +1,7 @@ //! NAL unit header and slice header parsing (7.3.1, 7.3.3). use crate::bitstream::BitReader; -use crate::h264::error::{Result, ensure}; +use crate::h264::error::{Error, Result, ensure}; use crate::h264::params::{Pps, Sps}; /// NAL unit types used by the decoder. @@ -199,9 +199,9 @@ impl SliceHeader { let mut num_ref_idx_active = [pps.num_ref_idx_l0_default_active, pps.num_ref_idx_l1_default_active]; if !slice_type.is_intra() { if r.read_flag()? { - num_ref_idx_active[0] = r.read_ue()? + 1; + num_ref_idx_active[0] = plus_one(r.read_ue()?)?; if slice_type.is_b() { - num_ref_idx_active[1] = r.read_ue()? + 1; + num_ref_idx_active[1] = plus_one(r.read_ue()?)?; } } let max = if field_pic { 32 } else { 16 }; @@ -214,108 +214,16 @@ impl SliceHeader { num_ref_idx_active[0] = 0; } ensure!(nal.nal_unit_type != 20 && nal.nal_unit_type != 21, "MVC slices are not supported"); - let mut ref_pic_list_mod = [Vec::new(), Vec::new()]; - for (list, mods) in ref_pic_list_mod.iter_mut().enumerate() { - let present = if list == 0 { !slice_type.is_intra() } else { slice_type.is_b() }; - if present && r.read_flag()? { - loop { - let idc = r.read_ue()?; - ensure!(idc <= 5, "modification_of_pic_nums_idc out of range"); - if idc == 3 { - break; - } - let value = r.read_ue()?; - mods.push(RefPicListMod { idc, value }); - ensure!(mods.len() <= 66, "too many ref_pic_list_modification entries"); - } - } - } - let mut pred_weight_table = None; - if (pps.weighted_pred && slice_type.is_p()) || (pps.weighted_bipred_idc == 1 && slice_type.is_b()) { - let luma_log2_denom = r.read_ue()?; - ensure!(luma_log2_denom <= 7, "luma_log2_weight_denom out of range"); - let mut chroma_log2_denom = 0; - if sps.chroma_array_type() != 0 { - chroma_log2_denom = r.read_ue()?; - ensure!(chroma_log2_denom <= 7, "chroma_log2_weight_denom out of range"); - } - let mut lists = [Vec::new(), Vec::new()]; - let nlists = if slice_type.is_b() { 2 } else { 1 }; - for (l, entries) in lists.iter_mut().enumerate().take(nlists) { - for _ in 0..num_ref_idx_active[l] { - let mut e = WeightEntry { luma_weight: 1 << luma_log2_denom, chroma_weight: [1 << chroma_log2_denom; 2], ..Default::default() }; - if r.read_flag()? { - e.luma_flag = true; - e.luma_weight = r.read_se()?; - e.luma_offset = r.read_se()?; - ensure!((-128..=127).contains(&e.luma_weight) && (-128..=127).contains(&e.luma_offset), "luma weight out of range"); - } - if sps.chroma_array_type() != 0 && r.read_flag()? { - e.chroma_flag = true; - for j in 0..2 { - e.chroma_weight[j] = r.read_se()?; - e.chroma_offset[j] = r.read_se()?; - ensure!( - (-128..=127).contains(&e.chroma_weight[j]) && (-128..=127).contains(&e.chroma_offset[j]), - "chroma weight out of range" - ); - } - } - entries.push(e); - } - } - let [l0, l1] = lists; - pred_weight_table = Some(PredWeightTable { luma_log2_denom, chroma_log2_denom, l0, l1 }); - } - let mut no_output_of_prior_pics = false; - let mut long_term_reference = false; - let mut adaptive_ref_pic_marking = false; - let mut mmcos = Vec::new(); - if nal.nal_ref_idc != 0 { - if idr { - no_output_of_prior_pics = r.read_flag()?; - long_term_reference = r.read_flag()?; - } else { - adaptive_ref_pic_marking = r.read_flag()?; - if adaptive_ref_pic_marking { - loop { - let op = r.read_ue()?; - ensure!(op <= 6, "memory_management_control_operation out of range"); - if op == 0 { - break; - } - let mut m = Mmco { - op, - difference_of_pic_nums_minus1: 0, - long_term_pic_num: 0, - long_term_frame_idx: 0, - max_long_term_frame_idx_plus1: 0, - }; - if op == 1 || op == 3 { - m.difference_of_pic_nums_minus1 = r.read_ue()?; - } - if op == 2 { - m.long_term_pic_num = r.read_ue()?; - } - if op == 3 || op == 6 { - m.long_term_frame_idx = r.read_ue()?; - } - if op == 4 { - m.max_long_term_frame_idx_plus1 = r.read_ue()?; - } - mmcos.push(m); - ensure!(mmcos.len() <= 66, "too many MMCOs"); - } - } - } - } + let ref_fields = parse_reference_fields(&mut r, nal, idr, slice_type, pps, sps, num_ref_idx_active)?; + let RefFields { ref_pic_list_mod, pred_weight_table, no_output_of_prior_pics, long_term_reference, adaptive_ref_pic_marking, mmcos } = + ref_fields; let mut cabac_init_idc = 0; if pps.entropy_coding_mode && !slice_type.is_intra() { cabac_init_idc = r.read_ue()?; ensure!(cabac_init_idc <= 2, "cabac_init_idc out of range"); } let slice_qp_delta = r.read_se()?; - let qp = pps.pic_init_qp + slice_qp_delta; + let qp = pps.pic_init_qp.checked_add(slice_qp_delta).ok_or_else(|| Error::Invalid("slice QP out of range".to_string()))?; ensure!((0..=51).contains(&qp), "slice QP {qp} out of range"); let mut sp_for_switch = false; let mut slice_qs_delta = 0; @@ -391,6 +299,139 @@ impl SliceHeader { } } +struct RefFields { + ref_pic_list_mod: [Vec; 2], + pred_weight_table: Option, + no_output_of_prior_pics: bool, + long_term_reference: bool, + adaptive_ref_pic_marking: bool, + mmcos: Vec, +} + +/// `num_ref_idx_active_minus1 + 1`, or the error its range check gives. +fn plus_one(v: u32) -> Result { + v.checked_add(1).ok_or_else(|| Error::Invalid("num_ref_idx_active out of range".to_string())) +} + +fn parse_reference_fields( + r: &mut BitReader<'_>, + nal: NalHeader, + idr: bool, + slice_type: SliceType, + pps: &Pps, + sps: &Sps, + num_ref_idx_active: [u32; 2], +) -> Result { + let ref_pic_list_mod = parse_ref_pic_list_mod(r, slice_type)?; + let pred_weight_table = parse_pred_weight_table(r, slice_type, pps, sps, num_ref_idx_active)?; + let (no_output_of_prior_pics, long_term_reference, adaptive_ref_pic_marking, mmcos) = parse_ref_pic_marking(r, nal, idr)?; + Ok(RefFields { ref_pic_list_mod, pred_weight_table, no_output_of_prior_pics, long_term_reference, adaptive_ref_pic_marking, mmcos }) +} + +fn parse_ref_pic_list_mod(r: &mut BitReader<'_>, slice_type: SliceType) -> Result<[Vec; 2]> { + let mut lists = [Vec::new(), Vec::new()]; + for (list, mods) in lists.iter_mut().enumerate() { + let present = if list == 0 { !slice_type.is_intra() } else { slice_type.is_b() }; + if present && r.read_flag()? { + loop { + let idc = r.read_ue()?; + ensure!(idc <= 5, "modification_of_pic_nums_idc out of range"); + if idc == 3 { + break; + } + mods.push(RefPicListMod { idc, value: r.read_ue()? }); + ensure!(mods.len() <= 66, "too many ref_pic_list_modification entries"); + } + } + } + Ok(lists) +} + +fn parse_pred_weight_table(r: &mut BitReader<'_>, slice_type: SliceType, pps: &Pps, sps: &Sps, active: [u32; 2]) -> Result> { + let weighted = (pps.weighted_pred && slice_type.is_p()) || (pps.weighted_bipred_idc == 1 && slice_type.is_b()); + if !weighted { + return Ok(None); + } + let luma_log2_denom = r.read_ue()?; + ensure!(luma_log2_denom <= 7, "luma_log2_weight_denom out of range"); + let chroma_log2_denom = if sps.chroma_array_type() == 0 { 0 } else { r.read_ue()? }; + ensure!(chroma_log2_denom <= 7, "chroma_log2_weight_denom out of range"); + let mut lists = [Vec::new(), Vec::new()]; + let list_count = if slice_type.is_b() { 2 } else { 1 }; + for (list, entries) in lists.iter_mut().enumerate().take(list_count) { + for _ in 0..active[list] { + entries.push(parse_weight_entry(r, luma_log2_denom, chroma_log2_denom, sps.chroma_array_type() != 0)?); + } + } + let [l0, l1] = lists; + Ok(Some(PredWeightTable { luma_log2_denom, chroma_log2_denom, l0, l1 })) +} + +fn parse_weight_entry(r: &mut BitReader<'_>, luma_denom: u32, chroma_denom: u32, has_chroma: bool) -> Result { + let mut entry = WeightEntry { luma_weight: 1 << luma_denom, chroma_weight: [1 << chroma_denom; 2], ..Default::default() }; + if r.read_flag()? { + entry.luma_flag = true; + entry.luma_weight = r.read_se()?; + entry.luma_offset = r.read_se()?; + ensure!((-128..=127).contains(&entry.luma_weight) && (-128..=127).contains(&entry.luma_offset), "luma weight out of range"); + } + if has_chroma && r.read_flag()? { + entry.chroma_flag = true; + for index in 0..2 { + entry.chroma_weight[index] = r.read_se()?; + entry.chroma_offset[index] = r.read_se()?; + ensure!( + (-128..=127).contains(&entry.chroma_weight[index]) && (-128..=127).contains(&entry.chroma_offset[index]), + "chroma weight out of range" + ); + } + } + Ok(entry) +} + +fn parse_ref_pic_marking(r: &mut BitReader<'_>, nal: NalHeader, idr: bool) -> Result<(bool, bool, bool, Vec)> { + let (mut no_output, mut long_term, mut adaptive) = (false, false, false); + let mut mmcos = Vec::new(); + if nal.nal_ref_idc == 0 { + return Ok((no_output, long_term, adaptive, mmcos)); + } + if idr { + no_output = r.read_flag()?; + long_term = r.read_flag()?; + } else { + adaptive = r.read_flag()?; + if adaptive { + parse_mmcos(r, &mut mmcos)?; + } + } + Ok((no_output, long_term, adaptive, mmcos)) +} + +fn parse_mmcos(r: &mut BitReader<'_>, mmcos: &mut Vec) -> Result<()> { + loop { + let op = r.read_ue()?; + ensure!(op <= 6, "memory_management_control_operation out of range"); + if op == 0 { + return Ok(()); + } + let mut m = Mmco { op, difference_of_pic_nums_minus1: 0, long_term_pic_num: 0, long_term_frame_idx: 0, max_long_term_frame_idx_plus1: 0 }; + if op == 1 || op == 3 { + m.difference_of_pic_nums_minus1 = r.read_ue()?; + } + if op == 2 { + m.long_term_pic_num = r.read_ue()?; + } + if op == 3 || op == 6 { + m.long_term_frame_idx = r.read_ue()?; + } + if op == 4 { + m.max_long_term_frame_idx_plus1 = r.read_ue()?; + } + mmcos.push(m); + ensure!(mmcos.len() <= 66, "too many MMCOs"); + } +} + /// POC decoding state carried between pictures (8.2.1). #[derive(Clone, Debug, Default)] pub struct PocState { @@ -426,20 +467,20 @@ impl PocState { let max_lsb = 1i32 << sps.log2_max_poc_lsb; let lsb = sh.pic_order_cnt_lsb as i32; 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 { - prev_msb - max_lsb + prev_msb.saturating_sub(max_lsb) } else { prev_msb }; - let top = msb + lsb; - let bottom = top + sh.delta_pic_order_cnt_bottom; + let top = msb.saturating_add(lsb); + let bottom = top.saturating_add(sh.delta_pic_order_cnt_bottom); Poc { top, bottom, msb, frame_num_offset: 0 } } 1 => { let frame_num_offset = self.frame_num_offset(sh.idr, sh.frame_num, sps); let n = sps.offset_for_ref_frame.len() as i32; - let mut abs_frame_num = if n != 0 { frame_num_offset + sh.frame_num as i32 } else { 0 }; + let mut abs_frame_num = if n != 0 { frame_num_offset.saturating_add(sh.frame_num as i32) } else { 0 }; if sh.nal_ref_idc == 0 && abs_frame_num > 0 { abs_frame_num -= 1; } @@ -447,17 +488,17 @@ impl PocState { if abs_frame_num > 0 { let cycle_cnt = (abs_frame_num - 1) / n; let in_cycle = (abs_frame_num - 1) % n; - let delta_cycle: i32 = sps.offset_for_ref_frame.iter().sum(); - expected = cycle_cnt.wrapping_mul(delta_cycle); + let delta_cycle: i32 = sps.offset_for_ref_frame.iter().fold(0i32, |a, &o| a.saturating_add(o)); + expected = cycle_cnt.saturating_mul(delta_cycle); for &o in &sps.offset_for_ref_frame[..=in_cycle as usize] { - expected += o; + expected = expected.saturating_add(o); } } if sh.nal_ref_idc == 0 { - expected += sps.offset_for_non_ref_pic; + expected = expected.saturating_add(sps.offset_for_non_ref_pic); } - let top = expected + sh.delta_pic_order_cnt[0]; - let bottom = top + sps.offset_for_top_to_bottom_field + sh.delta_pic_order_cnt[1]; + let top = expected.saturating_add(sh.delta_pic_order_cnt[0]); + let bottom = top.saturating_add(sps.offset_for_top_to_bottom_field).saturating_add(sh.delta_pic_order_cnt[1]); Poc { top, bottom, msb: 0, frame_num_offset } } _ => { @@ -465,9 +506,9 @@ impl PocState { let temp = if sh.idr { 0 } else if sh.nal_ref_idc == 0 { - 2 * (frame_num_offset + sh.frame_num as i32) - 1 + 2i32.saturating_mul(frame_num_offset.saturating_add(sh.frame_num as i32)).saturating_sub(1) } else { - 2 * (frame_num_offset + sh.frame_num as i32) + 2i32.saturating_mul(frame_num_offset.saturating_add(sh.frame_num as i32)) }; Poc { top: temp, bottom: temp, msb: 0, frame_num_offset } } @@ -478,7 +519,7 @@ impl PocState { if idr { 0 } else if self.prev_frame_num > frame_num { - self.prev_frame_num_offset + sps.max_frame_num() as i32 + self.prev_frame_num_offset.saturating_add(sps.max_frame_num() as i32) } else { self.prev_frame_num_offset } @@ -491,7 +532,7 @@ impl PocState { if sh.nal_ref_idc != 0 { if mmco5 { self.prev_ref_msb = 0; - self.prev_ref_lsb = poc.top - poc.frame(); + self.prev_ref_lsb = poc.top.saturating_sub(poc.frame()); } else { self.prev_ref_msb = poc.msb; self.prev_ref_lsb = sh.pic_order_cnt_lsb as i32; @@ -513,3 +554,101 @@ impl PocState { self.prev_frame_num = frame_num; } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitstream::BitWriter; + + fn sps_pps() -> (Sps, Pps) { + let mut w = BitWriter::new(); + w.write_bits(66, 8); + w.write_bits(0xC0, 8); + w.write_bits(30, 8); + w.write_ue(0); + w.write_ue(0); + w.write_ue(2); + w.write_ue(1); + w.write_bit(false); + w.write_ue(10); + w.write_ue(8); + w.write_bit(true); + w.write_bit(true); + w.write_bit(false); + w.write_bit(false); + w.rbsp_trailing(); + let sps = Sps::parse(&w.finish()).unwrap(); + let mut w = BitWriter::new(); + w.write_ue(0); + w.write_ue(0); + w.write_bit(false); + w.write_bit(false); + w.write_ue(0); + w.write_ue(0); + w.write_ue(0); + w.write_bit(false); + w.write_bits(0, 2); + w.write_se(0); + w.write_se(0); + w.write_se(0); + w.write_bit(false); + w.write_bit(false); + w.write_bit(false); + w.rbsp_trailing(); + let pps = Pps::parse(&w.finish(), &[Some(sps.clone())]).unwrap(); + (sps, pps) + } + + /// Parse a non-IDR slice header of `slice_type` whose override flag is set with the given counts. + fn parse_override(slice_type: u32, l0_minus1: u32, l1_minus1: u32, qp_delta: i32) -> Result { + let (sps, pps) = sps_pps(); + let mut w = BitWriter::new(); + w.write_ue(0); + w.write_ue(slice_type); + w.write_ue(0); + w.write_bits(0, 4); + if slice_type == 1 { + w.write_bit(true); // direct_spatial_mv_pred + } + w.write_bit(true); // num_ref_idx_active_override_flag + w.write_ue(l0_minus1); + if slice_type == 1 { + w.write_ue(l1_minus1); + } + w.write_bit(false); // ref_pic_list_modification_flag_l0 + if slice_type == 1 { + w.write_bit(false); + } + w.write_bit(false); // adaptive_ref_pic_marking_mode_flag + w.write_se(qp_delta); + w.rbsp_trailing(); + let nal = NalHeader { nal_ref_idc: 1, nal_unit_type: 1 }; + SliceHeader::parse(&w.finish(), nal, |_| Ok((&pps, &sps))).map(|(h, _, _)| h) + } + + #[test] + fn huge_num_ref_idx_override_is_invalid() { + assert!(matches!(parse_override(0, u32::MAX, 0, 0), Err(crate::h264::Error::Invalid(_)))); + assert!(matches!(parse_override(1, 0, u32::MAX, 0), Err(crate::h264::Error::Invalid(_)))); + assert!(matches!(parse_override(0, 16, 0, 0), Err(crate::h264::Error::Invalid(_)))); + assert_eq!(parse_override(0, 15, 0, 0).unwrap().num_ref_idx_active, [16, 0]); + } + + #[test] + fn huge_slice_qp_delta_is_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); + } +} diff --git a/crates/video/src/movie.rs b/crates/video/src/movie.rs index 752f701d..bbed0230 100644 --- a/crates/video/src/movie.rs +++ b/crates/video/src/movie.rs @@ -485,7 +485,7 @@ impl Movie { let s = st.next; st.next += 1; let pts = self.rank.get(s).copied().unwrap_or(0) as i64; - let data = match self.mp4.read_sample(&*self.src, self.track, s) { + let data = match self.read(s) { Ok(d) => d, Err(e) => { last_err = Some(e.to_string()); diff --git a/crates/video/src/tests.rs b/crates/video/src/tests.rs index 695a44ae..741db023 100644 --- a/crates/video/src/tests.rs +++ b/crates/video/src/tests.rs @@ -3,10 +3,12 @@ //! `image` encoder, muxed with the bundled MP4/MOV writer. When `ffmpeg` with libx264 is on PATH, //! real High-profile streams with B-frames are checked too (skipped otherwise). -use crate::isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; +use crate::isobmff::{Brand, ByteSource, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use crate::synth::{frame_color, h264_mp4, mjpeg_mov, prores_mov}; use crate::{Movie, VideoError}; use std::io::Cursor; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; fn expected_rgb(i: usize) -> [u8; 3] { let [y, cb, cr] = frame_color(i); @@ -118,6 +120,47 @@ fn prores_frame_size_is_checked_before_decoding() { assert!(matches!(m.frame(0), Err(VideoError::Decode(e)) if e.contains("56x48 frame in a 64x48 track"))); } +/// In-memory movie bytes, zero-padded up to `len` without storing the padding. Records the +/// largest read asked of it and refuses reads over 1 MiB (the test movies are much smaller). +struct PaddedSource { + bytes: Vec, + len: u64, + largest: AtomicUsize, +} + +impl ByteSource for PaddedSource { + fn len(&self) -> u64 { + self.len + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.largest.fetch_max(buf.len(), Ordering::Relaxed); + if buf.len() > 1 << 20 { + return Err(std::io::Error::other("read too large for the test source")); + } + let start = usize::try_from(offset).unwrap_or(usize::MAX); + for (i, b) in buf.iter_mut().enumerate() { + *b = self.bytes.get(start.saturating_add(i)).copied().unwrap_or(0); + } + Ok(()) + } +} + +#[test] +fn h264_sample_reads_are_capped() { + // One frame whose `stsz` entry claims 300 MiB, in a source long enough to hold it. + let mut bytes = h264_mp4(1, 1).unwrap(); + let at = bytes.windows(4).position(|w| w == b"stsz").unwrap(); + // After the type: version and flags, the constant size (0: one size per sample), the count. + let field = if bytes[at + 8..at + 12] == [0; 4] { at + 16 } else { at + 8 }; + bytes[field..field + 4].copy_from_slice(&(300u32 << 20).to_be_bytes()); + let src = Arc::new(PaddedSource { bytes, len: 1 << 30, largest: AtomicUsize::new(0) }); + let mut m = Movie::open_source(src.clone()).unwrap(); + let r = m.frame(0); + assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("sample larger than 256 MiB")), "{r:?}"); + let largest = src.largest.load(Ordering::Relaxed); + assert!(largest <= 256 << 20, "a {largest}-byte read reached the source"); +} + #[test] fn unsupported_codec_opens_but_does_not_decode() { let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml index 00ace628..284e7d54 100644 --- a/crates/vst3-host/Cargo.toml +++ b/crates/vst3-host/Cargo.toml @@ -47,14 +47,16 @@ harness = false name = "module_exit_shutdown" 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 # and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`. [lints.rust] unsafe_code = "deny" [lints.clippy] +cognitive_complexity = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" +too_many_arguments = "deny" type_complexity = "allow" diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index ed42bd6f..7330cd7a 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -39,7 +39,7 @@ mod process; pub(crate) use editor::EditorLink; pub(crate) use host::HostContext; -pub(crate) use instance::{Instance, PARAM_BYPASS, PARAM_HIDDEN, PARAM_READ_ONLY, RawParam}; +pub(crate) use instance::{Instance, PARAM_BYPASS, PARAM_HIDDEN, PARAM_READ_ONLY, ProcessInput, RawParam}; #[cfg(test)] use instance::{apply_transport, split_state}; pub(crate) use module::{Bundle, exit_modules, hex}; @@ -51,8 +51,9 @@ pub(crate) use process::{EventList, MemoryStream, ParamChanges}; const MAX_CLASSES: i32 = 1024; /// Most parameters read from one controller. pub(crate) const MAX_PARAMS: i32 = 4096; -/// Most audio buses per direction we provide buffers for. -const MAX_BUSES: i32 = 16; +/// Most audio buses per direction we provide buffers for. Multi-out instruments declare dozens +/// of (mostly inactive) aux buses, e.g. 32 for a drum sampler; every one still gets buffers. +const MAX_BUSES: i32 = 64; /// Most channels per audio bus. const MAX_BUS_CHANNELS: u32 = 64; /// Capacity of the per-block event lists (preallocated: `process` never allocates). diff --git a/crates/vst3-host/src/ffi/editor.rs b/crates/vst3-host/src/ffi/editor.rs index aba22549..89d344fa 100644 --- a/crates/vst3-host/src/ffi/editor.rs +++ b/crates/vst3-host/src/ffi/editor.rs @@ -1,5 +1,8 @@ use super::instance::Instance; -use super::*; +use super::{ + Class, ComPtr, ComWrapper, IComponentTrait, IEditControllerTrait, IPlugFrameTrait, IPlugViewTrait, Mutex, Ordering, PoisonError, STATE_MAGIC, + c_void, ok, read_c16, sb, sv, window, +}; // ---- editor (IPlugView) -------------------------------------------------------------------- diff --git a/crates/vst3-host/src/ffi/host.rs b/crates/vst3-host/src/ffi/host.rs index a2a2a216..5157e6b2 100644 --- a/crates/vst3-host/src/ffi/host.rs +++ b/crates/vst3-host/src/ffi/host.rs @@ -1,5 +1,8 @@ use super::module::tuid_bytes; -use super::*; +use super::{ + AtomicBool, Class, ComWrapper, EDIT_CAP, FUnknown, IComponentHandlerTrait, IHostApplicationTrait, Interface, Mutex, Ordering, PoisonError, + c_void, sb, sv, write_c16, +}; // ---- host-side COM objects ----------------------------------------------------------------- diff --git a/crates/vst3-host/src/ffi/instance.rs b/crates/vst3-host/src/ffi/instance.rs index 64e9d4e0..c7d783e0 100644 --- a/crates/vst3-host/src/ffi/instance.rs +++ b/crates/vst3-host/src/ffi/instance.rs @@ -2,7 +2,11 @@ use super::editor::EditorLink; use super::host::HostContext; use super::module::Bundle; use super::process::{BusBuffers, EventList, MemoryStream, NoteEvent, ParamChanges}; -use super::*; +use super::{ + Arc, ComPtr, ComWrapper, EVENT_CAP, IAudioProcessorTrait, IComponentTrait, IConnectionPointTrait, IEditControllerTrait, IMidiMappingTrait, + IPluginBaseTrait, MAX_BUS_CHANNELS, MAX_BUSES, MAX_PARAMS, MAX_STATE_BYTES, Mutex, Ordering, PARAM_QUEUE_CAP, PITCH_BEND, STATE_MAGIC, Transport, + Vst3Error, ok, read_c16, sb, sv, +}; // ---- instances ----------------------------------------------------------------------------- @@ -31,8 +35,15 @@ pub(crate) struct Layout { pub event_input: bool, } +/// `"input"` or `"output"`, for error messages. +fn dir_name(dir: sv::BusDirections) -> &'static str { + if dir == sv::BusDirections_::kInput { "input" } else { "output" } +} + pub(super) fn apply_transport(c: &mut sv::ProcessContext, t: &Transport) { - use sv::ProcessContext_::StatesAndFlags_::*; + use sv::ProcessContext_::StatesAndFlags_::{ + kBarPositionValid, kContTimeValid, kPlaying, kProjectTimeMusicValid, kRecording, kTempoValid, kTimeSigValid, + }; let mut state = kTempoValid | kTimeSigValid | kProjectTimeMusicValid | kBarPositionValid | kContTimeValid; if t.playing { state |= kPlaying; @@ -242,7 +253,17 @@ impl Instance { fn bus_count(&self, media: sv::MediaTypes, dir: sv::BusDirections) -> i32 { // SAFETY: valid component. - unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.clamp(0, MAX_BUSES) + unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.max(0) + } + + /// The plugin's audio buses in one direction. More than `MAX_BUSES` is an error: we never + /// hand a plugin fewer buses than it advertised. + fn audio_bus_count(&self, dir: sv::BusDirections) -> Result { + let n = self.bus_count(sv::MediaTypes_::kAudio, dir); + if n > MAX_BUSES { + return Err(Vst3Error::Process(format!("{n} audio {} buses (at most {MAX_BUSES} supported)", dir_name(dir)))); + } + Ok(n) } fn arrangement(&self, dir: sv::BusDirections, index: i32) -> Option { @@ -252,30 +273,41 @@ impl Instance { ok(unsafe { p.getBusArrangement(dir as sv::BusDirection, index, &mut arr) }).then_some(arr) } - fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> u32 { - if let Some(arr) = self.arrangement(dir, index) { - return arr.count_ones().min(MAX_BUS_CHANNELS); - } - // SAFETY: plain data; all-zero is valid. - let mut info: sv::BusInfo = unsafe { std::mem::zeroed() }; - // SAFETY: valid component; index below the bus count; `info` is writable. - if ok(unsafe { self.component.getBusInfo(sv::MediaTypes_::kAudio as sv::MediaType, dir as sv::BusDirection, index, &mut info) }) { - u32::try_from(info.channelCount).unwrap_or(0).min(MAX_BUS_CHANNELS) + /// Channels of one audio bus. More than `MAX_BUS_CHANNELS` is an error, as for the bus count. + fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> Result { + let channels = if let Some(arr) = self.arrangement(dir, index) { + arr.count_ones() } else { - 0 + // SAFETY: plain data; all-zero is valid. + let mut info: sv::BusInfo = unsafe { std::mem::zeroed() }; + // SAFETY: valid component; index below the bus count; `info` is writable. + if ok(unsafe { self.component.getBusInfo(sv::MediaTypes_::kAudio as sv::MediaType, dir as sv::BusDirection, index, &mut info) }) { + u32::try_from(info.channelCount).unwrap_or(0) + } else { + 0 + } + }; + if channels > MAX_BUS_CHANNELS { + return Err(Vst3Error::Process(format!( + "audio {} bus {index} has {channels} channels (at most {MAX_BUS_CHANNELS} supported)", + dir_name(dir) + ))); } + Ok(channels) } /// Stops processing, deactivates, negotiates the bus layout (main buses mono or stereo as /// asked, falling back to stereo and then to the plugin's own choice), activates the main - /// buses, sets up 32-bit processing and activates the component. + /// buses, sets up 32-bit processing and activates the component. A layout beyond + /// `MAX_BUSES` buses or `MAX_BUS_CHANNELS` channels per bus is an error, and the component + /// stays inactive. pub fn configure(&mut self, sample_rate: f64, max_frames: usize, mono: bool) -> Result { self.shutdown(); let audio = sv::MediaTypes_::kAudio; let event = sv::MediaTypes_::kEvent; let (din, dout) = (sv::BusDirections_::kInput, sv::BusDirections_::kOutput); - let nin = self.bus_count(audio, din); - let nout = self.bus_count(audio, dout); + let nin = self.audio_bus_count(din)?; + let nout = self.audio_bus_count(dout)?; let current = |s: &Self, dir: sv::BusDirections, n: i32| -> Vec { (0..n).map(|i| s.arrangement(dir, i).unwrap_or(sv::SpeakerArr::kStereo)).collect() }; @@ -305,8 +337,8 @@ impl Instance { log::debug!("vst3: plugin refused mono/stereo bus arrangements; using its own"); } let layout = Layout { - inputs: (0..nin).map(|i| self.bus_channels(din, i)).collect(), - outputs: (0..nout).map(|i| self.bus_channels(dout, i)).collect(), + inputs: (0..nin).map(|i| self.bus_channels(din, i)).collect::>()?, + outputs: (0..nout).map(|i| self.bus_channels(dout, i)).collect::>()?, event_input: self.bus_count(event, din) > 0, }; // Main buses on, aux buses off (VST3 still gets buffers for every bus). @@ -375,18 +407,8 @@ impl Instance { /// `controls` (time, id, value) one point at their time when it falls in this call (the last /// one wins per parameter), and `notes` (sorted by time) have their times made relative to /// `offset`. - pub fn process( - &mut self, - frames: usize, - inputs: &mut BusBuffers, - outputs: &mut BusBuffers, - params: &[(u32, f64)], - controls: &[(u32, u32, f64)], - notes: &[NoteEvent], - offset: u32, - project_time: i64, - transport: Option<&Transport>, - ) -> Result<(), Vst3Error> { + pub fn process(&mut self, input: ProcessInput<'_>) -> Result<(), Vst3Error> { + let ProcessInput { frames, inputs, outputs, params, controls, notes, offset, project_time, transport } = input; if !self.active { return Err(Vst3Error::Process("not active".into())); } @@ -450,6 +472,18 @@ impl Instance { } } +pub(crate) struct ProcessInput<'a> { + pub frames: usize, + pub inputs: &'a mut BusBuffers, + pub outputs: &'a mut BusBuffers, + pub params: &'a [(u32, f64)], + pub controls: &'a [(u32, u32, f64)], + pub notes: &'a [NoteEvent], + pub offset: u32, + pub project_time: i64, + pub transport: Option<&'a Transport>, +} + impl Drop for Instance { fn drop(&mut self) { // Close any editor and cut its handles off before the controller goes away. diff --git a/crates/vst3-host/src/ffi/module.rs b/crates/vst3-host/src/ffi/module.rs index 574156b7..61e415b4 100644 --- a/crates/vst3-host/src/ffi/module.rs +++ b/crates/vst3-host/src/ffi/module.rs @@ -2,7 +2,11 @@ use super::editor::{EditorInner, EditorLink}; use super::host::HostContext; use super::instance::Instance; use super::process::{EventList, ParamChanges}; -use super::*; +use super::{ + Arc, AtomicBool, AtomicUsize, ComPtr, ComWrapper, Duration, EVENT_CAP, FUnknown, IComponentTrait, IConnectionPointTrait, IEditControllerTrait, + IPluginBaseTrait, IPluginFactory2Trait, IPluginFactoryTrait, Interface, MAX_CLASSES, ManuallyDrop, Mutex, OnceLock, Ordering, PARAM_QUEUE_CAP, + Path, PathBuf, PoisonError, TryLockError, Vst3Error, c_char, c_int, c_void, mpsc, ok, read_c8, sb, scan, sv, +}; // ---- loaded binaries ----------------------------------------------------------------------- diff --git a/crates/vst3-host/src/ffi/process.rs b/crates/vst3-host/src/ffi/process.rs index 9e978496..bd0e1edd 100644 --- a/crates/vst3-host/src/ffi/process.rs +++ b/crates/vst3-host/src/ffi/process.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + AtomicI32, AtomicU32, AtomicU64, Class, ComWrapper, IBStreamTrait, IEventListTrait, IParamValueQueueTrait, IParameterChangesTrait, + MAX_BUS_CHANNELS, MAX_BUSES, MAX_STATE_BYTES, Mutex, Ordering, PoisonError, c_void, sb, sv, +}; /// One parameter's points for one block. SoundCraft sends at most one point per block; a /// plugin writing output changes may add more, of which only the last is kept. diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index 9c76446c..a0324f88 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -8,7 +8,7 @@ //! [`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. //! -//! 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 //! 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. diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index b2264a66..726ad98b 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -1,6 +1,6 @@ //! `Vst3Plugin`: a loaded VST3 instance behind the safe `soundcraft_dsp::Plugin` trait. -use crate::ffi::{AFTERTOUCH, BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP, PITCH_BEND}; +use crate::ffi::{AFTERTOUCH, BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP, PITCH_BEND, ProcessInput}; use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport, Unit}; use std::collections::HashMap; use std::sync::Arc; @@ -324,17 +324,17 @@ impl Plugin for Vst3Plugin { let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let notes = self.notes.get(..k).unwrap_or(&[]); - let r = self.inst.process( - n, - &mut self.inputs, - &mut self.outputs, - &self.pending, - &self.controls, + let r = self.inst.process(ProcessInput { + frames: n, + inputs: &mut self.inputs, + outputs: &mut self.outputs, + params: &self.pending, + controls: &self.controls, notes, offset, - self.position, - self.transport.as_ref(), - ); + project_time: self.position, + transport: self.transport.as_ref(), + }); self.pending.clear(); self.notes.drain(..k); self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0)); diff --git a/crates/vst3-host/tests/common/mod.rs b/crates/vst3-host/tests/common/mod.rs index e49b5a0e..32ce9177 100644 --- a/crates/vst3-host/tests/common/mod.rs +++ b/crates/vst3-host/tests/common/mod.rs @@ -35,7 +35,7 @@ pub fn fixture_dir() -> &'static Path { let bin = dir.join("vendor/Fixture.vst3/Contents").join(folder); std::fs::create_dir_all(&bin).unwrap(); std::fs::copy(&built, bin.join(exe)).unwrap(); - assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 2); + assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 6); dir }) } diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 2b7f73d9..d6af5332 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -1,4 +1,4 @@ -//! Test-only VST3 plugin for soundcraft-vst3-host (two audio module classes): +//! Test-only VST3 plugin for soundcraft-vst3-host (six audio module classes): //! //! - "Fixture Gain" (`Fx`): stereo or mono in → out × gain, with a separate edit controller //! (connected through IConnectionPoint, synced through the component state). Params: 7 "Gain" @@ -11,6 +11,11 @@ //! its edit controller). No audio input, one stereo output, one event input; every output //! sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and //! MIDI CC 7 through `IMidiMapping` (hidden parameter 1), set it to their value too. +//! - "Fixture Wide 16x64", "Fixture Wide 32 buses", "Fixture Wide 65 buses", "Fixture Wide 65 +//! channels" (`Fx`): effects advertising wide layouts (16 buses of 64 channels, 32 and 65 buses +//! of 2 channels, one bus of 65 channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel +//! of output bus 0 when the host's buffers match the advertised layout, -1.0 when they do not. +//! Report 3 samples of latency. //! //! The module checks the host's module lifecycle the way real plugins depend on it (see "module //! lifecycle" below) and aborts the process when it is broken. @@ -28,6 +33,10 @@ use vst3::{Class, ComRef, ComWrapper, uid}; const GAIN_CID: TUID = uid(0x5C0A1D00, 0x11112222, 0x33334444, 0x55556666); const GAIN_CTRL_CID: TUID = uid(0x5C0A1D01, 0x11112222, 0x33334444, 0x55556666); const SYNTH_CID: TUID = uid(0x5C0A1D02, 0x11112222, 0x33334444, 0x55556666); +const WIDE16_CID: TUID = uid(0x5C0A1D03, 0x11112222, 0x33334444, 0x55556666); +const WIDE65B_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666); +const WIDE65_CID: TUID = uid(0x5C0A1D05, 0x11112222, 0x33334444, 0x55556666); +const WIDE32_CID: TUID = uid(0x5C0A1D06, 0x11112222, 0x33334444, 0x55556666); fn copy_c8(src: &str, dst: &mut [c_char]) { for (d, s) in dst.iter_mut().zip(src.bytes().chain(std::iter::once(0))) { @@ -626,6 +635,98 @@ impl IEditControllerTrait for Synth { } } +// ---- wide layouts -------------------------------------------------------------------------- + +struct Wide { + buses: i32, + channels: i32, + _live: Live, +} + +impl Class for Wide { + type Interfaces = (IComponent, IAudioProcessor); +} + +impl IPluginBaseTrait for Wide { + unsafe fn initialize(&self, _context: *mut FUnknown) -> tresult { + kResultOk + } + unsafe fn terminate(&self) -> tresult { + kResultOk + } +} + +impl IComponentTrait for Wide { + unsafe fn getControllerClassId(&self, _class_id: *mut TUID) -> tresult { + kNotImplemented + } + unsafe fn setIoMode(&self, _mode: IoMode) -> tresult { + kResultOk + } + unsafe fn getBusCount(&self, media: MediaType, _dir: BusDirection) -> i32 { + if media == MediaTypes_::kAudio as MediaType { self.buses } else { 0 } + } + unsafe fn getBusInfo(&self, media: MediaType, dir: BusDirection, index: i32, bus: *mut BusInfo) -> tresult { + if media != MediaTypes_::kAudio as MediaType || !(0..self.buses).contains(&index) { + return kInvalidArgument; + } + audio_bus(bus, dir, "Wide"); + unsafe { (*bus).channelCount = self.channels }; + kResultOk + } + unsafe fn getRoutingInfo(&self, _i: *mut RoutingInfo, _o: *mut RoutingInfo) -> tresult { + kNotImplemented + } + unsafe fn activateBus(&self, _m: MediaType, _d: BusDirection, _i: i32, _s: TBool) -> tresult { + kResultOk + } + unsafe fn setActive(&self, _state: TBool) -> tresult { + kResultOk + } + unsafe fn setState(&self, _state: *mut IBStream) -> tresult { + kResultOk + } + unsafe fn getState(&self, _state: *mut IBStream) -> tresult { + kResultOk + } +} + +impl IAudioProcessorTrait for Wide { + unsafe fn setBusArrangements(&self, _i: *mut SpeakerArrangement, _n_in: i32, _o: *mut SpeakerArrangement, _n_out: i32) -> tresult { + kResultFalse + } + unsafe fn getBusArrangement(&self, _dir: BusDirection, _index: i32, _arr: *mut SpeakerArrangement) -> tresult { + kNotImplemented + } + unsafe fn canProcessSampleSize(&self, size: i32) -> tresult { + if size == SymbolicSampleSizes_::kSample32 as i32 { kResultOk } else { kNotImplemented } + } + unsafe fn getLatencySamples(&self) -> u32 { + 3 + } + unsafe fn setupProcessing(&self, _setup: *mut ProcessSetup) -> tresult { + kResultOk + } + unsafe fn setProcessing(&self, _state: TBool) -> tresult { + kResultOk + } + unsafe fn process(&self, data: *mut ProcessData) -> tresult { + let d = unsafe { &*data }; + let fits = + |bufs: *mut AudioBusBuffers, n: i32| n == self.buses && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.numChannels == self.channels); + let ok = fits(d.inputs, d.numInputs) && fits(d.outputs, d.numOutputs); + if d.numOutputs > 0 { + for ch in unsafe { channels(&*d.outputs, d.numSamples.max(0) as usize) } { + ch.fill(if ok { 1.0 } else { -1.0 }); + } + } + kResultOk + } + unsafe fn getTailSamples(&self) -> u32 { + 0 + } +} + // ---- factory ------------------------------------------------------------------------------- struct Factory { @@ -636,10 +737,14 @@ impl Class for Factory { type Interfaces = (IPluginFactory2,); } -const CLASSES: [(TUID, &str, &str, &str); 3] = [ +const CLASSES: [(TUID, &str, &str, &str); 7] = [ (GAIN_CID, "Audio Module Class", "Fixture Gain", "Fx"), (GAIN_CTRL_CID, "Component Controller Class", "Fixture Gain", ""), (SYNTH_CID, "Audio Module Class", "Fixture Synth", "Instrument|Synth"), + (WIDE16_CID, "Audio Module Class", "Fixture Wide 16x64", "Fx"), + (WIDE32_CID, "Audio Module Class", "Fixture Wide 32 buses", "Fx"), + (WIDE65B_CID, "Audio Module Class", "Fixture Wide 65 buses", "Fx"), + (WIDE65_CID, "Audio Module Class", "Fixture Wide 65 channels", "Fx"), ]; impl IPluginFactoryTrait for Factory { @@ -682,6 +787,11 @@ impl IPluginFactoryTrait for Factory { .to_com_ptr::() } else if cid == SYNTH_CID { ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::() + } else if let Some((buses, channels)) = [(WIDE16_CID, (16, 64)), (WIDE32_CID, (32, 2)), (WIDE65B_CID, (65, 2)), (WIDE65_CID, (1, 65))] + .into_iter() + .find_map(|(c, layout)| (c == cid).then_some(layout)) + { + ComWrapper::new(Wide { buses, channels, _live: Live::new(&OBJECTS) }).to_com_ptr::() } else { None }; diff --git a/crates/vst3-host/tests/it/fixture_e2e.rs b/crates/vst3-host/tests/it/fixture_e2e.rs index 01b4dadd..e24d6001 100644 --- a/crates/vst3-host/tests/it/fixture_e2e.rs +++ b/crates/vst3-host/tests/it/fixture_e2e.rs @@ -15,7 +15,7 @@ fn id_of(name: &str) -> String { fn scan_lists_both_audio_modules_but_not_the_controller_class() { let dir = fixture_dir(); let found = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]); - assert_eq!(found.len(), 2, "{found:?}"); + assert_eq!(found.len(), 6, "{found:?}"); let g = found.iter().find(|d| d.name == "Fixture Gain").unwrap(); // `uid()` lays out the first 8 bytes differently on Windows (COM GUID order). let want = if cfg!(windows) { "vst3:001D0A5C111122223333444455556666" } else { "vst3:5C0A1D00111122223333444455556666" }; @@ -311,6 +311,38 @@ fn host_set_controller_param_survives_state_round_trip() { assert!((r.param("12").unwrap() - 200.0).abs() < 0.01, "a loaded state replaces earlier edits: {:?}", r.param("12")); } +#[test] +fn layouts_beyond_the_host_limits_are_rejected_not_truncated() { + // Control: exactly at the limits (16 buses of 64 channels) activates and gets its full layout. + let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 16x64")).unwrap(); + p.prepare(44_100.0, 64, 2); + assert_eq!(p.latency(), 3); + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]); + // 65 buses, or a 65-channel bus: never activated, never processed with a cut-down layout. + for name in ["Fixture Wide 65 buses", "Fixture Wide 65 channels"] { + let mut p = soundcraft_vst3_host::create(&id_of(name)).unwrap(); + p.prepare(44_100.0, 64, 2); + assert_eq!(p.latency(), 0, "{name}: not activated"); + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + assert!(io.iter().flatten().all(|&x| x == 0.5), "{name}: the plugin never ran: {:?}", &io[0][..4]); + } +} + +#[test] +fn many_buses_like_multi_out_instruments_are_activated() { + // Multi-out instruments declare dozens of buses (SSD Sampler 5: 32); only the main one is + // active, but the plugin must still load, get buffers for every bus and play (#1247). + let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 32 buses")).unwrap(); + p.prepare(44_100.0, 64, 2); + assert_eq!(p.latency(), 3, "32-bus plugin activated"); + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + assert!(io.iter().flatten().all(|&x| x == 1.0), "32-bus layout delivered as advertised: {:?}", &io[0][..4]); +} + #[test] fn editor_view_is_probed_and_edits_come_back_as_plain_values() { let id = id_of("Fixture Gain"); diff --git a/docs/contributors.md b/docs/contributors.md index bf35050d..33312ac4 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **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 -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - 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): - [`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 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 @@ -36,20 +34,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```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. +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. diff --git a/docs/gaps.md b/docs/gaps.md index cd832000..a762162c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-25 · **Change:** trivial (G-04: Clip Looping crossfade and loop count; previously trivial: G-08 and G-20: built-in plugin count, the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (G-01: `setup.playback_engine` applies the device buffer in the desktop app; G-04: Clip Looping crossfade and loop count) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker** @@ -46,11 +46,16 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t device, sample rate and device buffer size (kept with the preferences, per machine; `ui.audio_device` for agents); f32 and 16/32-bit integer devices; ASIO behind the opt-in `asio` build feature (verified on Windows with an SSL 2+ at 128 samples). +- **Done (2026-10-25):** `setup.playback_engine` and `setup.hardware` apply `device` (the output + name) and `buffer_size` in the desktop app on the same path as the Playback Engine window + (`ui.audio_device`) and return the open device. `buffer_size` is any integer 16..=8192 and is + not rounded onto the window's power-of-two list. Headless (CLI, no app) still stores the request + and returns `applied: false`, with `buffer_size` null when unset; playback does not read those + session values. - **Missing:** routing to physical channels (I/O Setup), one duplex stream on one clock (input and output are still separate cpal streams), latency reporting and record-offset compensation, ASIO in official builds (owner decision: Steinberg's SDK is proprietary or GPLv3), JACK, and verification on macOS and Linux. The mix still runs in fixed 512-sample blocks. - `setup.playback_engine` still stores `device`/`buffer_size` in the session, unused. - **Evidence:** code above; Playback Engine window says "Mix block size: 512 samples"; issue #42 (no input device selection, forced monitoring feedback with virtual routing). - **Impact:** blocks tracking with any multichannel interface; latency makes overdubs hard. @@ -72,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 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. - **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 @@ -226,6 +231,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| | 2026-10-25 | trivial | G-04: Clip Looping (#1030) lacks the loop crossfade option, and Number of Loops stops at 512 while length and fill make up to 1000 loops | +| 2026-10-25 | trivial | G-01: `setup.playback_engine` and `setup.hardware` apply the device and buffer in the desktop app; headless returns `applied: false` | | 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing | | 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained | | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged | diff --git a/docs/release-playbook.md b/docs/release-playbook.md deleted file mode 100644 index aafba970..00000000 --- a/docs/release-playbook.md +++ /dev/null @@ -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). diff --git a/docs/releasing.md b/docs/releasing.md index b877757c..0fdc3502 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -5,7 +5,7 @@ signed installers for macOS, Windows, Linux, FreeBSD and the web, then creates o **draft** GitHub Release named `SoundCraft v`. Nobody sees a draft until a maintainer 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 (`soundcraft---.`, `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_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 -environment, ruleset and secrets for a new repo: craftrules `release/signing-setup.md`. +`GITHUB_TOKEN` creates the release. Only the final job gets `contents: write`. ## Icons diff --git a/docs/roadmap.md b/docs/roadmap.md index 035b2f7f..2e7eeeeb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # SoundCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (built-in plugin count; the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (`setup.playback_engine` applies the device buffer in the desktop app) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md). @@ -18,12 +18,11 @@ Hours are Opus 5.5 agent-hours. ## Alpha gate 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 -(craftrules `standards/progress-docs.md`, Stages). +main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are the alpha checklist. | Workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---:| -| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Fixed 512-sample block. Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 | +| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Device, rate and buffer are chosen in the Playback Engine window and, in the desktop app, by `setup.playback_engine` (headless returns `applied: false`). The mix still runs in fixed 512-sample blocks. Separate input and output streams. Takes held in RAM until stop (PR #84). | 35–55 | | Record MIDI from a controller and play it back through an instrument | **no** | Playback of drawn and imported MIDI works. No MIDI device layer at all, so nothing can be recorded and instruments can't be played live (G-02). | 20–35 | | Edit: select, trim, fade, comp playlists, Slip/Shuffle/Grid | yes (rough) | Every Edit menu command; property-tested with undo; open bugs #3, #5, #140 don't block the workflow | — | | Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 27 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) | @@ -89,6 +88,7 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Record-workflow evidence: `setup.playback_engine` applies the device and buffer in the desktop app; headless returns `applied: false` | | 2026-10-24 | trivial | 27 built-in processors, as the plugin registry holds; the sampler (#108) is no longer listed under plugin essentials | | 2026-10-14 | trivial | M8: reject oversized MCP batches before execution (#460) | | 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index db69e3b3..2992d93e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -96,7 +96,7 @@ every command is reachable from the CLI, the JSON control channel and MCP. ## 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 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 | | **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 alpha-gate failures. The full number gets partial credit from areas this user doesn't weight: editing breadth, five platforms built, agent control. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 23c4b54a..1fa184cc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # 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, 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 | | 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 | +| 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 | | Link timeline/edit, link track/edit, Tab to transient, insertion follows playback | ✅ | | | Mirrored MIDI, MIDI merge, layered editing | 🟡 | Stored and partly honoured | @@ -69,6 +70,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | 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-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) | diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 98d86199..cf19e8d4 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Build and package SoundCraft for FreeBSD: # -# $DIST/soundcraft--freebsd-x86_64.tar.gz a /usr/local-style tree: -# soundcraft--freebsd-x86_64/{bin, man/man1, share/applications, share/icons, +# $DIST/soundcraft--freebsd-.tar.gz a /usr/local-style tree ( is x86_64, or +# aarch64 when CARGO_BUILD_TARGET=aarch64-unknown-freebsd): +# soundcraft--freebsd-/{bin, man/man1, share/applications, share/icons, # share/mime, share/metainfo, share/doc/soundcraft, share/{bash-completion,zsh,fish}} # # Install by copying the tree's contents into /usr/local, e.g. @@ -23,7 +24,7 @@ SKIP_BUILD=0 while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; - -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -h | --help) sed -n '2,16p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 78becd59..92288276 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -20,10 +20,12 @@ APP_ID=ai.storyteller.soundcraft FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 +OCI=0 TARBALL="" -while [[ $# -gt 0 ]]; do +while [ $# -gt 0 ]; do case "$1" in --no-test) TEST=0; shift ;; + --oci) OCI=1; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; -*) echo "unknown argument: $1" >&2; exit 2 ;; *) TARBALL="$1"; shift ;; @@ -38,7 +40,7 @@ case "$ARCH" in esac BASENAME="soundcraft-$VERSION-linux-$ARCH" TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" -[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +51,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[[ -x "$WORK/stage/bin/soundcraft" ]] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; } +[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -61,8 +63,14 @@ OUT="$DIST/$BASENAME.flatpak" # --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" +if [[ "$OCI" = 1 ]]; then + OUT_OCI="$DIST/$BASENAME-oci" + rm -rf "$OUT_OCI" + flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable + echo "wrote OCI layout to $OUT_OCI" +fi -if [[ "$TEST" = 1 ]]; then +if [ "$TEST" = 1 ]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=soundcraft-cli "$APP_ID" --version diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh new file mode 100755 index 00000000..45287706 --- /dev/null +++ b/packaging/linux/flatpak-oci.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Build soundcraft as an OCI container layout and push it to GitHub Container Registry (GHCR) +# for consumption by Flatpak OCI clients (flatpak-oci-specs). +# +# Usage: +# packaging/linux/flatpak-oci.sh [--push] [TARBALL] +# +# Environment variables: +# REGISTRY Target registry image (default: ghcr.io/storytold/soundcraft) +# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH) +# GITHUB_TOKEN Token for GHCR authentication when --push is used +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" + +PUSH=0 +TARBALL="" +while [[ $# -gt 0 ]]; do + case "$1" in + --push) PUSH=1; shift ;; + -h | --help) sed -n '2,12p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac + +BASENAME="soundcraft-$VERSION-linux-$ARCH" +OUT_OCI="$DIST/$BASENAME-oci" + +echo "==> Building Flatpak OCI layout for $BASENAME" +"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}" + +[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; } + +REGISTRY="${REGISTRY:-ghcr.io/storytold/soundcraft}" +TAG="${TAG:-$VERSION-$ARCH}" + +echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)" + +if [[ "$PUSH" = 1 ]]; then + command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; } + + AUTH_ARGS=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN") + fi + + echo "==> Pushing to $REGISTRY:$TAG" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG" + + # Also update architecture-specific stable tag + echo "==> Pushing to $REGISTRY:stable-$ARCH" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH" + + echo "==> Push complete" +fi diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml index ff502f02..ffce6828 100644 --- a/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml @@ -18,11 +18,14 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: soundcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc + # Update checks reach GitHub (Help > Check for Updates and the startup check). + - --share=network # GPU access for the wgpu UI (Vulkan, or GL as a fallback). - --device=dri # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml index b30c9ba2..02bad718 100644 --- a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml @@ -23,11 +23,14 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: soundcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc + # Update checks reach GitHub (Help > Check for Updates and the startup check). + - --share=network # GPU access for the wgpu UI (Vulkan, or GL as a fallback). - --device=dri # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its