diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..35049cbc --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.gitignore b/.gitignore index 0807ae54..75bd19e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ *~ plan/ +/target +/dist +/corpus +/log +.DS_Store +/.claude/ +/target-*/ +/craft-fonts/ plans/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8a1d3b2a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,68 @@ +# SoundCraft — instructions for agents + +SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../printcraft` (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 status, milestones and the parity estimate. +3. Work autonomously; don't stop to ask unless a decision is genuinely the user's. + +## ⚠️ Assets: the absolute rule +**No Avid, Pro Tools, Adobe or other proprietary iconography, images, sounds, presets or artwork — ever.** This is not negotiable. +- Every icon in SoundCraft is drawn in code (`crates/ui-egui/src/icons.rs`) or is an original/open asset. +- Every bundled asset (image, icon, sound, font, preset) must be original, public domain (CC0), or under a permissive open license (MIT, Apache-2.0, BSD, OFL, CC-BY with attribution), or contributed by someone who made it and licenses it openly. +- **Every bundled asset gets a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (path, author, source, license) in the same commit that adds it. `cargo xtask assets` enforces this; CI fails otherwise. +- Never copy files out of the Pro Tools bundle (`/Applications/Pro Tools.app`): no plugins, presets, icons, sounds, session templates or text. Observing the running app (screenshots kept under `plan/`, menu names) is fine; copying its resources is not. +- Demo audio is synthesised in code (`crates/engine/src/demo.rs`). Demo and README media must be ours or public domain. +- The ArtCraft trademarks in `docs/brand/` are the only exception, under `docs/brand/LICENSE-brand.txt`. + +## Clean-room +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). +- No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). +- Errors are `Result` + `?`. An unfinished feature returns an "unsupported" error. +- Input-derived numbers are hostile (files, commands, MCP/control params): `get()` not `[i]`, checked/saturating arithmetic, no NaN casts, cap allocations. +- The audio callback never blocks, allocates in steady state, or panics. +- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. +- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test. + +## Architecture (layers enforced by `cargo xtask layers`) +| Layer | Crate | Role | +|---|---|---| +| L0 | `soundcraft-time`, `soundcraft-audio-io`, `soundcraft-midi` | Timebases/tempo/grid; audio file formats + peaks; Standard MIDI Files + MIDI ops | +| L1 | `soundcraft-dsp` | Plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utility, instruments), offline processing, meters, FFT | +| L2 | `soundcraft-model` | The session document (tracks, playlists, clips, mixer, routing, automation, markers, groups) | +| L3 | `soundcraft-mix` | Mix engine shared by realtime playback and offline bounce | +| L4 | `soundcraft-engine`, `soundcraft-playback` | Command registry, undo, editing, import/export/bounce, parity catalog; audio device I/O (cpal) | +| L5 | `soundcraft-automation` | MCP server + control-channel client | +| L6 | `soundcraft-ui-egui` | The swappable UI (egui) | +| apps | `soundcraft`, `soundcraft-cli`, `soundcraft-web` | Entry points | + +Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin: panels read engine state and act through `engine.execute(id, params)`. + +## Everything is a command +User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (windows, dialogs) live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). Menus, shortcuts, the CLI, the control channel and MCP all dispatch the same ids. Programmatic calls never open dialogs; only menu-style invocation (`ui.menu.invoke`) does. New commands go in a new module with its own `specs()`, registered by one line in `cmd/mod.rs`. + +Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity.md`; a test enforces a floor that only rises. + +## Running and looking at the app +- `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port is printed). +- Drive it: JSON lines over TCP, e.g. `{"id":1,"method":"engine.execute","params":{"command":"track.new","params":{"count":2,"format":"stereo"}}}`, then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. +- Offscreen UI render (no window, no focus stealing): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl]`. +- Headless: `soundcraft-cli run --demo --cmd 'mix.volume={"track":"Kick","db":-6}' --bounce out.wav`; MCP: `soundcraft-cli mcp [--connect PORT]`. +- **For UI work, look at the result** (render, read the PNG) and compare with `plan/protools/screenshots/`. +- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. +- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically; delete your target dir when done (disk). + +## Quality gates (before every commit) +`cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit. + +## Release +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 manifest), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`. + +## Roadmap +`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone lands. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 00000000..1f7708e9 --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1,38 @@ +# Attribution + +Every asset bundled with or committed to SoundCraft is listed here with its author, source and +license. `cargo xtask assets` fails CI when a tracked asset file has no row. + +**Rule:** no Avid, Pro Tools, Adobe or other proprietary icons, images, sounds or presets — ever. +Assets must be original, public domain (CC0) or permissively licensed. See `AGENTS.md`. + +## Icons + +All UI icons (tools, transport, track buttons) are drawn procedurally in +`crates/ui-egui/src/icons.rs` by the SoundCraft contributors (MIT OR Apache-2.0). No icon image +files are used. + +## Audio + +The demo session's audio (drums, bass, pad) and its MIDI parts are synthesised in code in +`crates/engine/src/demo.rs` by the SoundCraft contributors (MIT OR Apache-2.0). No sample files +are bundled. + +## Fonts + +SoundCraft bundles no font files. It uses the default fonts shipped inside the `egui` crate +(Ubuntu-Light: Ubuntu Font Licence 1.0; Hack: MIT; Noto Emoji: OFL-1.1; emoji-icon-font: OFL-1.1/MIT) +and, at runtime, the operating system's own UI fonts when available (never redistributed). + +## Files + +| 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 | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b5c917a3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +Read `AGENTS.md`: it is the entry point for agents in this repo. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..60bd5b73 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1562 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.2", + "objc2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "objc2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "soundcraft-audio-io" +version = "0.1.0" +dependencies = [ + "log", + "proptest", + "serde", + "serde_json", + "symphonia", + "thiserror 2.0.21", +] + +[[package]] +name = "soundcraft-dsp" +version = "0.1.0" +dependencies = [ + "log", + "proptest", + "rustfft", + "serde", + "serde_json", +] + +[[package]] +name = "soundcraft-engine" +version = "0.1.0" +dependencies = [ + "log", + "proptest", + "serde", + "serde_json", + "soundcraft-audio-io", + "soundcraft-dsp", + "soundcraft-midi", + "soundcraft-mix", + "soundcraft-model", + "soundcraft-time", + "thiserror 2.0.21", +] + +[[package]] +name = "soundcraft-midi" +version = "0.1.0" +dependencies = [ + "proptest", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "soundcraft-mix" +version = "0.1.0" +dependencies = [ + "log", + "soundcraft-audio-io", + "soundcraft-dsp", + "soundcraft-midi", + "soundcraft-model", + "soundcraft-time", +] + +[[package]] +name = "soundcraft-model" +version = "0.1.0" +dependencies = [ + "proptest", + "serde", + "serde_json", + "soundcraft-audio-io", + "soundcraft-midi", + "soundcraft-time", + "thiserror 2.0.21", +] + +[[package]] +name = "soundcraft-playback" +version = "0.1.0" +dependencies = [ + "cpal", + "log", + "soundcraft-dsp", + "soundcraft-mix", + "soundcraft-model", + "soundcraft-time", +] + +[[package]] +name = "soundcraft-time" +version = "0.1.0" +dependencies = [ + "proptest", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "zerocopy" +version = "0.8.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6400fc4bb7426f6faac3fb07566122bb18622e3022bd2c36426a92ed489b1dbe" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99c27cc7525bad4df59a45e073c45b2c2aa820efb588234cc23a547ec69a34db" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..486bd08c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,65 @@ +[workspace] +resolver = "3" +members = ["crates/*", "xtask"] +default-members = ["crates/*", "xtask"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.90" +repository = "https://github.com/storytold/soundcraft" + +[workspace.dependencies] +# internal +soundcraft-time = { path = "crates/time" } +soundcraft-audio-io = { path = "crates/audio-io" } +soundcraft-midi = { path = "crates/midi" } +soundcraft-dsp = { path = "crates/dsp" } +soundcraft-model = { path = "crates/model" } +soundcraft-mix = { path = "crates/mix" } +soundcraft-engine = { path = "crates/engine" } +soundcraft-playback = { path = "crates/playback" } +soundcraft-automation = { path = "crates/automation" } +soundcraft-ui-egui = { path = "crates/ui-egui" } + +# foundation +thiserror = "2" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = { version = "1", features = ["float_roundtrip"] } +log = "0.4" +rayon = "1" +proptest = "1" + +# audio +symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] } +rustfft = "6" +cpal = "0.16" + +# ui +egui = "0.36" +eframe = { version = "0.36", default-features = false } +egui_kittest = "0.36" +rfd = "0.17" +image = { version = "0.25", default-features = false, features = ["png"] } + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 1 + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 16 +debug = 0 diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..5078dac1 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ArtCraft Team and the SoundCraft contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..34c26cc3 --- /dev/null +++ b/NOTICE @@ -0,0 +1,17 @@ +SoundCraft +Copyright (c) 2026 ArtCraft Team and the SoundCraft contributors. +Licensed under the MIT License or the Apache License, Version 2.0, at your option +(LICENSE-MIT, LICENSE-APACHE). + +The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are +not open source and are not covered by the license above; their terms are in +docs/brand/LICENSE-brand.txt. + +Third-party material + +This product bundles third-party material under its own licenses, listed below. The per-file +list (author, source, license) is in ATTRIBUTION.md. + +None: every bundled asset is original to SoundCraft. Icons are drawn in code; demo audio is +synthesised in code. The egui default fonts (Ubuntu-Light, Hack, Noto Emoji, Emoji Icon Font) ship +inside the egui crate under their own open licenses (see ATTRIBUTION.md). diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..4b6c79f5 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true diff --git a/crates/audio-io/Cargo.toml b/crates/audio-io/Cargo.toml new file mode 100644 index 00000000..d3450a28 --- /dev/null +++ b/crates/audio-io/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "soundcraft-audio-io" +description = "SoundCraft audio file I/O: WAV/BWF/RF64, AIFF/AIFF-C native readers and writers, FLAC encoder, symphonia-backed decode of compressed formats, waveform peaks" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true +log.workspace = true +symphonia.workspace = true + +[dev-dependencies] +proptest.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/audio-io/src/aiff.rs b/crates/audio-io/src/aiff.rs new file mode 100644 index 00000000..dfa76234 --- /dev/null +++ b/crates/audio-io/src/aiff.rs @@ -0,0 +1,232 @@ +//! Native AIFF / AIFF-C reader and writer (Apple "Audio Interchange File Format" 1.3 and the +//! AIFF-C draft: big-endian IFF chunks, 80-bit IEEE extended sample rate). + +use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, clamp_float, deinterleave, tag, validate_buffer}; +use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat}; + +/// AIFF-C version 1 timestamp for the FVER chunk. +const AIFC_VERSION_1: u32 = 0xA280_5140; +const MAX_SAMPLE_RATE: f64 = 10_000_000.0; + +pub(crate) struct AiffLayout { + pub info: AudioInfo, + pub kind: PcmKind, + pub data_start: usize, + pub data_len: usize, +} + +pub(crate) enum AiffParse { + Native(AiffLayout), + /// Compressed AIFF-C (ulaw, alaw, ima4, ...): handed to symphonia. + Foreign, +} + +/// True when the bytes start like an AIFF or AIFF-C file. +pub(crate) fn is_aiff(b: &[u8]) -> bool { + tag(b, 0).is_some_and(|t| &t == b"FORM") && tag(b, 8).is_some_and(|t| &t == b"AIFF" || &t == b"AIFC") +} + +/// Decode an 80-bit IEEE 754 extended-precision float. +pub(crate) fn extended_to_f64(b: &[u8]) -> Option { + let se = be_u16(b, 0)?; + let hi = u64::from(be_u32(b, 2)?); + let lo = u64::from(be_u32(b, 6)?); + let mant = (hi << 32) | lo; + if mant == 0 { + return Some(0.0); + } + let exp = i32::from(se & 0x7FFF); + if exp == 0x7FFF { + return None; // inf / NaN + } + let v = mant as f64 * 2f64.powi(exp - 16383 - 63); + Some(if se & 0x8000 != 0 { -v } else { v }) +} + +/// Encode a positive integer as an 80-bit IEEE 754 extended float (exact). +pub(crate) fn u32_to_extended(r: u32) -> [u8; 10] { + let mut out = [0u8; 10]; + if r == 0 { + return out; + } + let e = 31 - r.leading_zeros(); + let exp = (16383 + e) as u16; + let mant = u64::from(r) << (63 - e); + out[..2].copy_from_slice(&exp.to_be_bytes()); + out[2..].copy_from_slice(&mant.to_be_bytes()); + out +} + +pub(crate) fn parse(b: &[u8]) -> Result { + if !is_aiff(b) { + return Err(AudioError::Malformed("not an AIFF/AIFF-C file".into())); + } + let is_aifc = tag(b, 8).is_some_and(|t| &t == b"AIFC"); + let mut comm: Option<(i16, u32, i16, f64, [u8; 4])> = None; + let mut ssnd: Option<(usize, usize)> = None; + + let mut pos: usize = 12; + let mut chunks = 0usize; + while let (Some(id), Some(size32)) = (tag(b, pos), be_u32(b, pos + 4)) { + chunks += 1; + if chunks > 100_000 { + break; + } + let body = pos.checked_add(8).ok_or_else(|| AudioError::Malformed("chunk offset overflow".into()))?; + let remaining = b.len().saturating_sub(body); + let declared = usize::try_from(size32).unwrap_or(usize::MAX); + let len = declared.min(remaining); + let c = b.get(body..body + len).unwrap_or_default(); + match &id { + b"COMM" => { + let short = || AudioError::Malformed("COMM chunk too short".into()); + let channels = be_u16(c, 0).ok_or_else(short)? as i16; + let frames = be_u32(c, 2).ok_or_else(short)?; + let bits = be_u16(c, 6).ok_or_else(short)? as i16; + let rate = extended_to_f64(c.get(8..18).ok_or_else(short)?).ok_or_else(|| AudioError::Malformed("invalid sample rate".into()))?; + let compression = if is_aifc { tag(c, 18).ok_or_else(short)? } else { *b"NONE" }; + comm = Some((channels, frames, bits, rate, compression)); + } + b"SSND" if ssnd.is_none() => { + let offset = usize::try_from(be_u32(c, 0).unwrap_or(0)).unwrap_or(usize::MAX); + let start = body.saturating_add(8).saturating_add(offset).min(b.len()); + let data_len = len.saturating_sub(8).saturating_sub(offset); + ssnd = Some((start, data_len)); + } + _ => {} + } + if declared > remaining { + break; + } + pos = match body.checked_add(declared).and_then(|p| p.checked_add(declared & 1)) { + Some(p) => p, + None => break, + }; + } + + let (channels, frames, bits, rate, compression) = comm.ok_or_else(|| AudioError::Malformed("AIFF has no COMM chunk".into()))?; + if channels <= 0 { + return Err(AudioError::Malformed("AIFF declares zero or negative channels".into())); + } + if !(1.0..=MAX_SAMPLE_RATE).contains(&rate) { + return Err(AudioError::Malformed(format!("AIFF sample rate {rate} out of range"))); + } + let sample_rate = rate.round() as u32; + let int_kind = |big_endian: bool| -> Result<(PcmKind, SampleFormat)> { + if !(1..=32).contains(&bits) { + return Err(AudioError::Unsupported(format!("AIFF sample size {bits}"))); + } + let bytes = (bits as usize).div_ceil(8); + let sf = match bits { + 1..=8 => SampleFormat::Int8, + 9..=16 => SampleFormat::Int16, + 17..=24 => SampleFormat::Int24, + _ => SampleFormat::Int32, + }; + Ok((PcmKind::Int { bytes, big_endian, unsigned: false }, sf)) + }; + let (kind, sample_format) = match &compression { + b"NONE" | b"twos" => int_kind(true)?, + b"sowt" => int_kind(false)?, + b"fl32" | b"FL32" => (PcmKind::Float { bytes: 4, big_endian: true }, SampleFormat::Float32), + b"fl64" | b"FL64" => (PcmKind::Float { bytes: 8, big_endian: true }, SampleFormat::Float64), + _ => return Ok(AiffParse::Foreign), + }; + let (data_start, data_len) = ssnd.unwrap_or((b.len(), 0)); + let block = kind.bytes().saturating_mul(channels as usize).max(1); + let frames = u64::from(frames).min((data_len / block) as u64); + Ok(AiffParse::Native(AiffLayout { + info: AudioInfo { format: FileFormat::Aiff, sample_format, sample_rate, channels: channels as u16, frames, bwf: None }, + kind, + data_start, + data_len, + })) +} + +pub(crate) fn decode(b: &[u8], layout: &AiffLayout) -> Result { + let end = layout.data_start.saturating_add(layout.data_len).min(b.len()); + let data = b.get(layout.data_start..end).unwrap_or_default(); + let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?; + Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels }) +} + +fn push_chunk(out: &mut Vec, id: &[u8; 4], body: &[u8]) { + out.extend_from_slice(id); + out.extend_from_slice(&u32::try_from(body.len()).unwrap_or(u32::MAX).to_be_bytes()); + out.extend_from_slice(body); + if body.len() % 2 == 1 { + out.push(0); + } +} + +/// Encode AIFF (integer depths) or AIFF-C `fl32` (Float32). +pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { + validate_buffer(buf)?; + let channels = buf.channels.len(); + let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?; + let frames = buf.frames(); + let frames_u32 = u32::try_from(frames).map_err(|_| AudioError::TooLarge("too many frames for AIFF".into()))?; + let (bits, is_float): (u16, bool) = match depth { + BitDepth::Int16 => (16, false), + BitDepth::Int24 => (24, false), + BitDepth::Int32 => (32, false), + BitDepth::Float32 => (32, true), + }; + let bytes = usize::from(bits / 8); + let data_len = + bytes.checked_mul(channels).and_then(|b| b.checked_mul(frames)).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; + + let mut comm = Vec::with_capacity(40); + comm.extend_from_slice(&channels_i16.to_be_bytes()); + comm.extend_from_slice(&frames_u32.to_be_bytes()); + comm.extend_from_slice(&bits.to_be_bytes()); + comm.extend_from_slice(&u32_to_extended(buf.sample_rate)); + if is_float { + comm.extend_from_slice(b"fl32"); + let name = b"32-bit float"; + comm.push(name.len() as u8); + comm.extend_from_slice(name); + if (name.len() + 1) % 2 == 1 { + comm.push(0); + } + } + + let mut head = Vec::with_capacity(128); + head.extend_from_slice(b"FORM"); + head.extend_from_slice(&[0, 0, 0, 0]); // patched below + head.extend_from_slice(if is_float { b"AIFC" } else { b"AIFF" }); + if is_float { + push_chunk(&mut head, b"FVER", &AIFC_VERSION_1.to_be_bytes()); + } + push_chunk(&mut head, b"COMM", &comm); + head.extend_from_slice(b"SSND"); + let ssnd_len = u32::try_from(data_len.saturating_add(8)).map_err(|_| AudioError::TooLarge("AIFF data exceeds 4 GiB".into()))?; + head.extend_from_slice(&ssnd_len.to_be_bytes()); + head.extend_from_slice(&[0u8; 8]); // offset, blockSize + + let total = head.len().saturating_add(data_len).saturating_add(data_len & 1); + let form_len = u32::try_from(total - 8).map_err(|_| AudioError::TooLarge("AIFF file exceeds 4 GiB".into()))?; + if let Some(slot) = head.get_mut(4..8) { + slot.copy_from_slice(&form_len.to_be_bytes()); + } + + let mut out = Vec::new(); + out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?; + out.extend_from_slice(&head); + let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels); + for f in 0..frames { + for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) { + let s = ch.get(f).copied().unwrap_or(0.0); + if is_float { + out.extend_from_slice(&clamp_float(s).to_be_bytes()); + } else { + let v = q.quantize(s).to_be_bytes(); + out.extend_from_slice(v.get(4 - bytes..).unwrap_or_default()); + } + } + } + if data_len % 2 == 1 { + out.push(0); + } + Ok(out) +} diff --git a/crates/audio-io/src/buffer.rs b/crates/audio-io/src/buffer.rs new file mode 100644 index 00000000..78f25af6 --- /dev/null +++ b/crates/audio-io/src/buffer.rs @@ -0,0 +1,71 @@ +//! Planar `f32` audio buffer. + +/// Planar f32 audio. +/// +/// Every channel is expected to hold the same number of frames; [`AudioBuffer::frames`] reports the +/// shortest channel so a ragged buffer never causes out-of-bounds access. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct AudioBuffer { + /// Sample rate in Hz. + pub sample_rate: u32, + /// One `Vec` of samples per channel. + pub channels: Vec>, +} + +impl AudioBuffer { + /// A silent buffer with `channels` channels of `frames` frames each. + pub fn new(sample_rate: u32, channels: usize, frames: usize) -> Self { + Self { sample_rate, channels: vec![vec![0.0; frames]; channels] } + } + + /// Number of frames (the length of the shortest channel; 0 when there are no channels). + pub fn frames(&self) -> usize { + self.channels.iter().map(Vec::len).min().unwrap_or(0) + } + + /// Number of channels. + pub fn num_channels(&self) -> usize { + self.channels.len() + } + + /// Duration in seconds (0 when the sample rate is 0). + pub fn duration_secs(&self) -> f64 { + if self.sample_rate == 0 { + return 0.0; + } + self.frames() as f64 / f64::from(self.sample_rate) + } + + /// Interleaved copy of the samples (frame-major: `L0 R0 L1 R1 ...`). + pub fn interleaved(&self) -> Vec { + let frames = self.frames(); + let mut out = Vec::with_capacity(frames.saturating_mul(self.channels.len())); + for f in 0..frames { + for ch in &self.channels { + out.push(ch.get(f).copied().unwrap_or(0.0)); + } + } + out + } + + /// Build a planar buffer from interleaved samples. A trailing partial frame is dropped; + /// `channels == 0` yields an empty buffer. + pub fn from_interleaved(sr: u32, channels: usize, data: &[f32]) -> Self { + if channels == 0 { + return Self { sample_rate: sr, channels: Vec::new() }; + } + let frames = data.len() / channels; + let mut out: Vec> = (0..channels).map(|_| Vec::with_capacity(frames)).collect(); + for frame in data.chunks_exact(channels) { + for (ch, &s) in out.iter_mut().zip(frame) { + ch.push(s); + } + } + Self { sample_rate: sr, channels: out } + } + + /// Absolute peak over all channels (NaN samples are ignored). + pub fn peak(&self) -> f32 { + self.channels.iter().flat_map(|c| c.iter()).fold(0.0f32, |acc, &s| acc.max(s.abs())) + } +} diff --git a/crates/audio-io/src/flac.rs b/crates/audio-io/src/flac.rs new file mode 100644 index 00000000..2f5550b5 --- /dev/null +++ b/crates/audio-io/src/flac.rs @@ -0,0 +1,379 @@ +//! A small pure-Rust FLAC encoder (RFC 9639): fixed blocksize, independent channels, and per +//! subframe the cheapest of CONSTANT, VERBATIM or FIXED (orders 0–4) prediction with +//! partitioned Rice-coded residuals. + +use crate::pcm::{Quantizer, validate_buffer}; +use crate::{AudioBuffer, AudioError, BitDepth, Result}; + +const BLOCK_SIZE: usize = 4096; +const MAX_PARTITION_ORDER: u32 = 6; + +struct BitWriter { + out: Vec, + acc: u64, + nbits: u32, +} + +impl BitWriter { + fn new() -> Self { + Self { out: Vec::new(), acc: 0, nbits: 0 } + } + + /// Write the low `n` (≤ 32) bits of `v`, MSB first. + fn write(&mut self, v: u64, n: u32) { + if n == 0 { + return; + } + let n = n.min(32); + let mask = (1u64 << n) - 1; + self.acc = (self.acc << n) | (v & mask); + self.nbits += n; + while self.nbits >= 8 { + self.nbits -= 8; + self.out.push((self.acc >> self.nbits) as u8); + } + self.acc &= (1u64 << self.nbits) - 1; + } + + fn write_signed(&mut self, v: i64, n: u32) { + self.write(v as u64, n); + } + + /// `q` zero bits followed by a one bit. + fn write_unary(&mut self, mut q: u64) { + while q >= 32 { + self.write(0, 32); + q -= 32; + } + self.write(1, (q as u32) + 1); + } + + fn align(&mut self) { + if self.nbits > 0 { + let pad = 8 - self.nbits; + self.write(0, pad); + } + } + + fn into_bytes(mut self) -> Vec { + self.align(); + self.out + } +} + +fn crc8(data: &[u8]) -> u8 { + let mut crc: u8 = 0; + for &b in data { + crc ^= b; + for _ in 0..8 { + crc = if crc & 0x80 != 0 { (crc << 1) ^ 0x07 } else { crc << 1 }; + } + } + crc +} + +fn crc16(data: &[u8]) -> u16 { + let mut crc: u16 = 0; + for &b in data { + crc ^= u16::from(b) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { (crc << 1) ^ 0x8005 } else { crc << 1 }; + } + } + crc +} + +/// FLAC's UTF-8-like variable-length integer coding (up to 36 bits). +fn write_utf8_number(w: &mut BitWriter, v: u64) { + if v < 0x80 { + w.write(v, 8); + return; + } + // Payload bits available for an n-byte coding: 5n + 1 (n = 2..=6), 36 for n = 7. + let n: u32 = (2..=7).find(|&n| if n == 7 { true } else { v < (1u64 << (5 * n + 1)) }).unwrap_or(7); + let cont_bits = 6 * (n - 1); + let lead_payload = if n == 7 { 0 } else { 7 - n }; + let prefix: u64 = (0xFFu64 << (8 - n)) & 0xFF; + let lead = prefix | ((v >> cont_bits) & ((1u64 << lead_payload) - 1)); + w.write(lead, 8); + for i in (0..n - 1).rev() { + w.write(0x80 | ((v >> (6 * i)) & 0x3F), 8); + } +} + +fn sample_rate_code(r: u32) -> (u64, Option<(u64, u32)>) { + match r { + 88_200 => (1, None), + 176_400 => (2, None), + 192_000 => (3, None), + 8_000 => (4, None), + 16_000 => (5, None), + 22_050 => (6, None), + 24_000 => (7, None), + 32_000 => (8, None), + 44_100 => (9, None), + 48_000 => (10, None), + 96_000 => (11, None), + r if r % 1000 == 0 && r / 1000 <= 255 => (12, Some((u64::from(r / 1000), 8))), + r if r <= 65_535 => (13, Some((u64::from(r), 16))), + r if r % 10 == 0 && r / 10 <= 65_535 => (14, Some((u64::from(r / 10), 16))), + _ => (0, None), + } +} + +fn bps_code(bps: u32) -> u64 { + match bps { + 8 => 1, + 12 => 2, + 16 => 4, + 20 => 5, + 24 => 6, + _ => 0, // from STREAMINFO + } +} + +fn zigzag(r: i64) -> u64 { + ((r << 1) ^ (r >> 63)) as u64 +} + +/// Fixed-predictor residuals for `order`, or `None` when any residual leaves the i32 range. +fn fixed_residual(x: &[i64], order: usize) -> Option> { + let mut res = Vec::with_capacity(x.len().saturating_sub(order)); + for n in order..x.len() { + let g = |k: usize| x.get(n - k).copied().unwrap_or(0); + let r = match order { + 0 => g(0), + 1 => g(0) - g(1), + 2 => g(0) - 2 * g(1) + g(2), + 3 => g(0) - 3 * g(1) + 3 * g(2) - g(3), + _ => g(0) - 4 * g(1) + 6 * g(2) - 4 * g(3) + g(4), + }; + if r < i64::from(i32::MIN) || r > i64::from(i32::MAX) { + return None; + } + res.push(r); + } + Some(res) +} + +/// Best Rice parameter and its cost in bits for one partition. +fn best_rice(part: &[u64]) -> (u32, u64) { + if part.is_empty() { + return (0, 0); + } + let sum: u64 = part.iter().sum(); + let mean = sum / part.len() as u64; + let guess = if mean == 0 { 0 } else { 63 - mean.leading_zeros() }; + let mut best = (0u32, u64::MAX); + for k in guess.saturating_sub(1)..=(guess + 1).min(30) { + let cost = part.len() as u64 * u64::from(k + 1) + part.iter().map(|&u| u >> k).sum::(); + if cost < best.1 { + best = (k, cost); + } + } + best +} + +/// Residual coding plan: partition order, per-partition parameters, total bits. +struct RicePlan { + order: u32, + params: Vec, + bits: u64, + wide: bool, +} + +fn plan_residual(u: &[u64], block_len: usize, pred_order: usize) -> RicePlan { + let mut best: Option = None; + for p in 0..=MAX_PARTITION_ORDER { + let parts = 1usize << p; + if !block_len.is_multiple_of(parts) || block_len / parts <= pred_order { + break; + } + let per = block_len / parts; + let mut params = Vec::with_capacity(parts); + let mut bits = 0u64; + let mut start = 0usize; + for i in 0..parts { + let len = if i == 0 { per - pred_order } else { per }; + let part = u.get(start..start + len).unwrap_or_default(); + start += len; + let (k, cost) = best_rice(part); + params.push(k); + bits += cost; + } + let wide = params.iter().any(|&k| k > 14); + bits += 6 + parts as u64 * if wide { 5 } else { 4 }; + if best.as_ref().is_none_or(|b| bits < b.bits) { + best = Some(RicePlan { order: p, params, bits, wide }); + } + } + best.unwrap_or(RicePlan { order: 0, params: vec![0], bits: u64::MAX, wide: false }) +} + +fn write_residual(w: &mut BitWriter, u: &[u64], block_len: usize, pred_order: usize, plan: &RicePlan) { + w.write(u64::from(plan.wide), 2); + w.write(u64::from(plan.order), 4); + let parts = 1usize << plan.order; + let per = block_len / parts; + let mut start = 0usize; + for (i, &k) in plan.params.iter().enumerate() { + let len = if i == 0 { per - pred_order } else { per }; + w.write(u64::from(k), if plan.wide { 5 } else { 4 }); + for &v in u.get(start..start + len).unwrap_or_default() { + w.write_unary(v >> k); + w.write(v, k); + } + start += len; + } +} + +fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) { + let n = x.len(); + let first = x.first().copied().unwrap_or(0); + if x.iter().all(|&s| s == first) { + w.write(0, 1); + w.write(0, 6); + w.write(0, 1); + w.write_signed(first, bps); + return; + } + let verbatim_bits = n as u64 * u64::from(bps); + let mut best: Option<(usize, Vec, RicePlan)> = None; + for order in 0..=4usize.min(n.saturating_sub(1)) { + let Some(res) = fixed_residual(x, order) else { continue }; + let u: Vec = res.into_iter().map(zigzag).collect(); + let plan = plan_residual(&u, n, order); + let bits = plan.bits.saturating_add(order as u64 * u64::from(bps)); + if bits < verbatim_bits && best.as_ref().is_none_or(|b| bits < b.2.bits.saturating_add(b.0 as u64 * u64::from(bps))) { + best = Some((order, u, plan)); + } + } + match best { + Some((order, u, plan)) => { + w.write(0, 1); + w.write(8 + order as u64, 6); + w.write(0, 1); + for &s in x.iter().take(order) { + w.write_signed(s, bps); + } + write_residual(w, &u, n, order, &plan); + } + None => { + w.write(0, 1); + w.write(1, 6); + w.write(0, 1); + for &s in x { + w.write_signed(s, bps); + } + } + } +} + +pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { + validate_buffer(buf)?; + let channels = buf.channels.len(); + if channels > 8 { + return Err(AudioError::Unsupported(format!("FLAC supports at most 8 channels, got {channels}"))); + } + if buf.sample_rate > 655_350 { + return Err(AudioError::Unsupported(format!("FLAC sample rate {} exceeds 655350 Hz", buf.sample_rate))); + } + let bps: u32 = match depth { + BitDepth::Int16 => 16, + BitDepth::Int24 => 24, + BitDepth::Int32 => 32, + BitDepth::Float32 => return Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())), + }; + let frames = buf.frames(); + let total = frames as u64; + if total >= 1u64 << 36 { + return Err(AudioError::TooLarge("too many frames for FLAC".into())); + } + + let mut out = Vec::new(); + out.try_reserve(frames.saturating_mul(channels).saturating_mul(bps as usize / 8) / 2) + .map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?; + out.extend_from_slice(b"fLaC"); + let mut si = BitWriter::new(); + si.write(1, 1); // last metadata block + si.write(0, 7); // STREAMINFO + si.write(34, 24); + si.write(BLOCK_SIZE as u64, 16); + si.write(BLOCK_SIZE as u64, 16); + si.write(0, 24); + si.write(0, 24); + si.write(u64::from(buf.sample_rate), 20); + si.write(channels as u64 - 1, 3); + si.write(u64::from(bps - 1), 5); + si.write(total >> 32, 4); + si.write(total & 0xFFFF_FFFF, 32); + for _ in 0..4 { + si.write(0, 32); // MD5 unknown + } + out.extend_from_slice(&si.into_bytes()); + + let (sr_code, sr_extra) = sample_rate_code(buf.sample_rate); + let mut quantizers = Quantizer::per_channel(bps, dither, channels); + let mut block: Vec = Vec::with_capacity(BLOCK_SIZE); + let mut frame_no: u64 = 0; + let mut start = 0usize; + while start < frames { + let len = (frames - start).min(BLOCK_SIZE); + let mut w = BitWriter::new(); + w.write(0x3FFE, 14); + w.write(0, 1); + w.write(0, 1); // fixed blocksize stream + w.write(7, 4); // 16-bit (blocksize-1) follows + w.write(sr_code, 4); + w.write(channels as u64 - 1, 4); + w.write(bps_code(bps), 3); + w.write(0, 1); + write_utf8_number(&mut w, frame_no); + w.write(len as u64 - 1, 16); + if let Some((v, n)) = sr_extra { + w.write(v, n); + } + let header = w.into_bytes(); + let mut w = BitWriter::new(); + for &b in &header { + w.write(u64::from(b), 8); + } + w.write(u64::from(crc8(&header)), 8); + for (ch, q) in buf.channels.iter().zip(quantizers.iter_mut()) { + block.clear(); + block.extend(ch.get(start..start + len).unwrap_or_default().iter().map(|&s| i64::from(q.quantize(s)))); + write_subframe(&mut w, &block, bps); + } + let mut frame = w.into_bytes(); + let crc = crc16(&frame); + frame.extend_from_slice(&crc.to_be_bytes()); + out.extend_from_slice(&frame); + start += len; + frame_no += 1; + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crc_known_values() { + // CRC-8/SMBUS and CRC-16/UMTS (FLAC's polynomials) of "123456789". + assert_eq!(crc8(b"123456789"), 0xF4); + assert_eq!(crc16(b"123456789"), 0xFEE8); + } + + #[test] + fn utf8_numbers() { + let enc = |v| { + let mut w = BitWriter::new(); + write_utf8_number(&mut w, v); + w.into_bytes() + }; + assert_eq!(enc(0x7F), vec![0x7F]); + assert_eq!(enc(0x80), vec![0xC2, 0x80]); + assert_eq!(enc(0x800), vec![0xE0, 0xA0, 0x80]); + } +} diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs new file mode 100644 index 00000000..c73a00ed --- /dev/null +++ b/crates/audio-io/src/lib.rs @@ -0,0 +1,263 @@ +//! SoundCraft audio file I/O. +//! +//! Bytes in, bytes out (no filesystem access, so the crate also builds for `wasm32`): +//! +//! * [`detect_format`] / [`probe`] / [`decode`] read WAV (PCM 8/16/24/32, float 32/64, +//! `WAVE_FORMAT_EXTENSIBLE`, RF64/BW64, BWF `bext`) and AIFF/AIFF-C (big-endian, `sowt`, +//! `fl32`/`fl64`) with native readers, and everything else (FLAC, MP3, Ogg Vorbis, AAC/ALAC in +//! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`. +//! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in +//! encoder), with optional TPDF dither. +//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod aiff; +mod buffer; +mod flac; +mod pcm; +pub mod peaks; +mod symph; +mod wav; + +pub use buffer::AudioBuffer; + +/// Errors from probing, decoding and encoding. +#[derive(Debug, thiserror::Error)] +pub enum AudioError { + /// The format, codec or option is valid but not supported. + #[error("unsupported audio: {0}")] + Unsupported(String), + /// The input is corrupt or not what it claims to be. + #[error("malformed audio: {0}")] + Malformed(String), + /// The input (or requested output) exceeds a size limit. + #[error("audio too large: {0}")] + TooLarge(String), + /// Encoding failed (e.g. an empty buffer). + #[error("audio encode failed: {0}")] + Encode(String), +} + +/// Result alias for this crate. +pub type Result = std::result::Result; + +/// Container / codec family of an audio file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum FileFormat { + Wav, + Aiff, + Flac, + Mp3, + Ogg, + Aac, + Alac, + Caf, + Other, +} + +/// Stored sample representation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SampleFormat { + Int8, + Int16, + Int24, + Int32, + Float32, + Float64, + /// A lossy codec (MP3, AAC, Vorbis, ...): no fixed bit depth. + Compressed, +} + +/// Broadcast Wave (`bext`) metadata. +#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct BwfInfo { + pub description: String, + pub originator: String, + pub originator_reference: String, + /// `yyyy-mm-dd`. + pub origination_date: String, + /// `hh:mm:ss`. + pub origination_time: String, + /// Sample count since midnight of the first sample (the timestamp Pro Tools spots to). + pub time_reference: u64, +} + +/// What [`probe`] learns about a file without decoding all of it. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct AudioInfo { + pub format: FileFormat, + pub sample_format: SampleFormat, + pub sample_rate: u32, + pub channels: u16, + pub frames: u64, + pub bwf: Option, +} + +impl AudioInfo { + /// Duration in seconds. + pub fn duration_secs(&self) -> f64 { + if self.sample_rate == 0 { 0.0 } else { self.frames as f64 / f64::from(self.sample_rate) } + } +} + +/// Bit depth for [`encode`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum BitDepth { + Int16, + Int24, + Int32, + Float32, +} + +/// Options for [`encode`]. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct EncodeOptions { + /// `Wav`, `Aiff` or `Flac`; anything else is [`AudioError::Unsupported`]. + pub format: FileFormat, + pub bit_depth: BitDepth, + /// Add TPDF dither when quantizing to an integer depth. + pub dither: bool, + /// Write a BWF `bext` chunk (WAV only; ignored for other formats). + pub bwf: Option, +} + +impl Default for EncodeOptions { + fn default() -> Self { + Self { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None } + } +} + +/// Size of an ID3v2 tag at the start of `b`, if there is one. +fn id3v2_len(b: &[u8]) -> Option { + if b.get(..3)? != b"ID3" { + return None; + } + let flags = *b.get(5)?; + let size = b.get(6..10)?.iter().fold(0usize, |acc, &x| (acc << 7) | usize::from(x & 0x7F)); + Some(10 + size + if flags & 0x10 != 0 { 10 } else { 0 }) +} + +fn detect_by_magic(b: &[u8], depth: u32) -> Option { + let head = |n: usize| b.get(..n); + if wav::is_wav(b) { + return Some(FileFormat::Wav); + } + if aiff::is_aiff(b) { + return Some(FileFormat::Aiff); + } + match head(4)? { + b"fLaC" => return Some(FileFormat::Flac), + b"OggS" => return Some(FileFormat::Ogg), + b"caff" => return Some(FileFormat::Caf), + [0x1A, 0x45, 0xDF, 0xA3] => return Some(FileFormat::Other), + _ => {} + } + if b.get(4..8) == Some(b"ftyp") { + // MP4 / M4A: the sample entry inside `stsd` names the codec. + let found = b.windows(4).position(|w| w == b"stsd"); + let codec = found.and_then(|p| b.get(p.checked_add(16)?..p.checked_add(20)?)); + return Some(if codec == Some(b"alac") { FileFormat::Alac } else { FileFormat::Aac }); + } + if let Some(len) = id3v2_len(b) { + if depth == 0 + && let Some(f) = b.get(len..).and_then(|rest| detect_by_magic(rest, 1)) + { + return Some(f); + } + return Some(FileFormat::Mp3); + } + let (b0, b1) = (*b.first()?, *b.get(1)?); + if b0 == 0xFF && b1 & 0xE0 == 0xE0 { + let layer = (b1 >> 1) & 0x3; + if b1 & 0xF0 == 0xF0 && layer == 0 { + return Some(FileFormat::Aac); // ADTS + } + if layer != 0 { + return Some(FileFormat::Mp3); + } + } + None +} + +fn detect_by_ext(ext: &str) -> FileFormat { + match ext.trim_start_matches('.').to_ascii_lowercase().as_str() { + "wav" | "wave" | "bwf" | "rf64" => FileFormat::Wav, + "aif" | "aiff" | "aifc" => FileFormat::Aiff, + "flac" => FileFormat::Flac, + "mp3" | "mp2" | "mp1" => FileFormat::Mp3, + "ogg" | "oga" => FileFormat::Ogg, + "aac" | "m4a" | "mp4" | "adts" => FileFormat::Aac, + "caf" => FileFormat::Caf, + _ => FileFormat::Other, + } +} + +/// Identify a file by its magic bytes, falling back to the extension hint (`"wav"`, `".flac"`). +pub fn detect_format(bytes: &[u8], ext_hint: Option<&str>) -> FileFormat { + detect_by_magic(bytes, 0).unwrap_or_else(|| ext_hint.map(detect_by_ext).unwrap_or(FileFormat::Other)) +} + +/// Read a file's format, sample rate, channel count, length and BWF metadata. +pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result { + let format = detect_format(bytes, ext_hint); + match format { + FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? { + wav::WavParse::Native(l) => Ok(l.info), + wav::WavParse::Foreign { bwf } => { + let mut info = symph::probe(bytes, Some("wav"), format)?; + info.bwf = bwf; + Ok(info) + } + }, + FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? { + aiff::AiffParse::Native(l) => Ok(l.info), + aiff::AiffParse::Foreign => symph::probe(bytes, Some("aiff"), format), + }, + _ => symph::probe(bytes, ext_hint, format), + } +} + +/// Decode a whole file to planar `f32`. +pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioBuffer)> { + let format = detect_format(bytes, ext_hint); + match format { + FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? { + wav::WavParse::Native(l) => { + let buf = wav::decode(bytes, &l)?; + let mut info = l.info; + info.frames = buf.frames() as u64; + Ok((info, buf)) + } + wav::WavParse::Foreign { bwf } => { + let (mut info, buf) = symph::decode(bytes, Some("wav"), format)?; + info.bwf = bwf; + Ok((info, buf)) + } + }, + FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? { + aiff::AiffParse::Native(l) => { + let buf = aiff::decode(bytes, &l)?; + let mut info = l.info; + info.frames = buf.frames() as u64; + Ok((info, buf)) + } + aiff::AiffParse::Foreign => symph::decode(bytes, Some("aiff"), format), + }, + _ => symph::decode(bytes, ext_hint, format), + } +} + +/// Encode `buf` as WAV/BWF, AIFF/AIFF-C or FLAC. Samples are clamped to [-1, 1]. +pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result> { + match opts.format { + FileFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), false), + FileFormat::Aiff => aiff::encode(buf, opts.bit_depth, opts.dither), + FileFormat::Flac => flac::encode(buf, opts.bit_depth, opts.dither), + other => Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))), + } +} + +/// Encode a WAV file in the RF64 layout regardless of size (normally used only above 4 GiB). +pub fn encode_wav_rf64(buf: &AudioBuffer, opts: &EncodeOptions) -> Result> { + wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true) +} diff --git a/crates/audio-io/src/pcm.rs b/crates/audio-io/src/pcm.rs new file mode 100644 index 00000000..a1fe894f --- /dev/null +++ b/crates/audio-io/src/pcm.rs @@ -0,0 +1,176 @@ +//! Byte-level PCM helpers shared by the WAV, AIFF and FLAC code: bounded readers, sample +//! decoding to `f32`, and quantization (with optional TPDF dither) for encoding. + +use crate::{AudioError, Result}; + +/// Maximum number of decoded `f32` samples (all channels) we will produce: 4 GiB of output. +pub(crate) const MAX_DECODE_SAMPLES: u64 = (4u64 << 30) / 4; + +/// Little-endian `u16` at `pos`. +pub(crate) fn le_u16(b: &[u8], pos: usize) -> Option { + let s = b.get(pos..pos.checked_add(2)?)?; + Some(u16::from_le_bytes([*s.first()?, *s.get(1)?])) +} + +/// Little-endian `u32` at `pos`. +pub(crate) fn le_u32(b: &[u8], pos: usize) -> Option { + let s: [u8; 4] = b.get(pos..pos.checked_add(4)?)?.try_into().ok()?; + Some(u32::from_le_bytes(s)) +} + +/// Little-endian `u64` at `pos`. +pub(crate) fn le_u64(b: &[u8], pos: usize) -> Option { + let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?; + Some(u64::from_le_bytes(s)) +} + +/// Big-endian `u16` at `pos`. +pub(crate) fn be_u16(b: &[u8], pos: usize) -> Option { + let s: [u8; 2] = b.get(pos..pos.checked_add(2)?)?.try_into().ok()?; + Some(u16::from_be_bytes(s)) +} + +/// Big-endian `u32` at `pos`. +pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option { + let s: [u8; 4] = b.get(pos..pos.checked_add(4)?)?.try_into().ok()?; + Some(u32::from_be_bytes(s)) +} + +/// Four-byte tag at `pos`. +pub(crate) fn tag(b: &[u8], pos: usize) -> Option<[u8; 4]> { + b.get(pos..pos.checked_add(4)?)?.try_into().ok() +} + +/// How the samples in a PCM byte stream are encoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PcmKind { + /// Signed (or, for 1-byte WAV, unsigned) integers occupying `bytes` bytes each, left-justified. + Int { bytes: usize, big_endian: bool, unsigned: bool }, + /// IEEE float, 4 or 8 bytes. + Float { bytes: usize, big_endian: bool }, +} + +impl PcmKind { + pub(crate) fn bytes(self) -> usize { + match self { + PcmKind::Int { bytes, .. } | PcmKind::Float { bytes, .. } => bytes, + } + } +} + +/// Decode one sample from exactly `kind.bytes()` bytes. Returns 0 for a short slice. +fn decode_sample(s: &[u8], kind: PcmKind) -> f32 { + match kind { + PcmKind::Int { bytes, big_endian, unsigned } => { + if bytes == 0 || bytes > 4 || s.len() < bytes { + return 0.0; + } + let mut acc: u32 = 0; + for i in 0..bytes { + let idx = if big_endian { i } else { bytes - 1 - i }; + let mut byte = s.get(idx).copied().unwrap_or(0); + if unsigned && i == 0 { + byte ^= 0x80; + } + acc = (acc << 8) | u32::from(byte); + } + let shifted = acc << (32 - 8 * bytes as u32); + (f64::from(shifted as i32) / 2_147_483_648.0) as f32 + } + PcmKind::Float { bytes, big_endian } => { + let v = match bytes { + 4 => { + let Some(a) = s.get(..4).and_then(|x| <[u8; 4]>::try_from(x).ok()) else { return 0.0 }; + if big_endian { f32::from_be_bytes(a) } else { f32::from_le_bytes(a) } + } + 8 => { + let Some(a) = s.get(..8).and_then(|x| <[u8; 8]>::try_from(x).ok()) else { return 0.0 }; + (if big_endian { f64::from_be_bytes(a) } else { f64::from_le_bytes(a) }) as f32 + } + _ => 0.0, + }; + if v.is_finite() { v } else { 0.0 } + } + } +} + +/// Deinterleave `frames` frames of `channels`-channel PCM from `data` into planar `f32`. +pub(crate) fn deinterleave(data: &[u8], kind: PcmKind, channels: usize, frames: u64) -> Result>> { + let bytes = kind.bytes(); + let block = bytes.checked_mul(channels).filter(|&b| b > 0).ok_or_else(|| AudioError::Malformed("zero-sized sample frame".into()))?; + let available = (data.len() / block) as u64; + let frames = frames.min(available); + let total = frames.checked_mul(channels as u64).ok_or_else(|| AudioError::TooLarge("sample count overflows".into()))?; + if total > MAX_DECODE_SAMPLES { + return Err(AudioError::TooLarge(format!("{total} samples exceeds the {MAX_DECODE_SAMPLES}-sample decode limit"))); + } + let frames = usize::try_from(frames).map_err(|_| AudioError::TooLarge("frame count exceeds address space".into()))?; + let mut out: Vec> = (0..channels).map(|_| Vec::with_capacity(frames)).collect(); + for frame in data.chunks_exact(block).take(frames) { + for (ch, s) in out.iter_mut().zip(frame.chunks_exact(bytes)) { + ch.push(decode_sample(s, kind)); + } + } + Ok(out) +} + +/// Converts `f32` samples to integers of a given bit depth, clamping to [-1, 1] and optionally +/// adding triangular (TPDF) dither of ±1 LSB. +pub(crate) struct Quantizer { + bits: u32, + dither: bool, + rng: u64, +} + +impl Quantizer { + /// A quantizer for one channel; each channel gets an independent dither sequence. + pub(crate) fn new(bits: u32, dither: bool, channel: usize) -> Self { + let rng = 0x9E37_79B9_7F4A_7C15u64 ^ (channel as u64).wrapping_add(1).wrapping_mul(0xD1B5_4A32_D192_ED03); + Self { bits: bits.clamp(2, 32), dither, rng: if rng == 0 { 1 } else { rng } } + } + + /// One quantizer per channel. + pub(crate) fn per_channel(bits: u32, dither: bool, channels: usize) -> Vec { + (0..channels).map(|c| Self::new(bits, dither, c)).collect() + } + + /// Uniform in [0, 1) from a xorshift64* generator (deterministic, no OS entropy needed). + fn uniform(&mut self) -> f64 { + let mut x = self.rng; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.rng = x; + (x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 + } + + pub(crate) fn quantize(&mut self, x: f32) -> i32 { + let x = if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) }; + let scale = (1u64 << (self.bits - 1)) as f64; + let mut v = f64::from(x) * scale; + if self.dither { + v += self.uniform() - self.uniform(); + } + let max = scale - 1.0; + v.round().clamp(-scale, max) as i32 + } +} + +/// Clamp a float sample to [-1, 1], mapping NaN to 0. +pub(crate) fn clamp_float(x: f32) -> f32 { + if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) } +} + +/// Check that a buffer is encodable: at least one channel and a non-zero sample rate. +pub(crate) fn validate_buffer(buf: &crate::AudioBuffer) -> Result<()> { + if buf.channels.is_empty() { + return Err(AudioError::Encode("buffer has no channels".into())); + } + if buf.channels.len() > usize::from(u16::MAX) { + return Err(AudioError::Encode("too many channels".into())); + } + if buf.sample_rate == 0 { + return Err(AudioError::Encode("sample rate is 0".into())); + } + Ok(()) +} diff --git a/crates/audio-io/src/peaks.rs b/crates/audio-io/src/peaks.rs new file mode 100644 index 00000000..d13c1ea9 --- /dev/null +++ b/crates/audio-io/src/peaks.rs @@ -0,0 +1,197 @@ +//! Multi-resolution min/max overview for waveform drawing. +//! +//! Level 0 holds the (min, max) of each block of [`BLOCK`] samples; each following level merges +//! [`FANOUT`] blocks of the level below, until a level has a single block. Range queries combine +//! the coarsest whole blocks that fit inside the range, so a query costs O(log n) regardless of +//! zoom. + +/// Samples per level-0 block. +pub const BLOCK: usize = 32; +/// Blocks of level `n` merged into one block of level `n + 1`. +pub const FANOUT: usize = 4; +/// Upper bound on the number of columns [`Peaks::columns`] will produce. +pub const MAX_COLUMNS: usize = 1 << 20; + +/// Multi-resolution min/max overview for waveform drawing. Level 0 = blocks of 32 samples; each next level merges 4. +#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Peaks { + len: usize, + levels: Vec>, +} + +const EMPTY: (f32, f32) = (f32::INFINITY, f32::NEG_INFINITY); + +fn merge(a: (f32, f32), b: (f32, f32)) -> (f32, f32) { + (a.0.min(b.0), a.1.max(b.1)) +} + +fn finish(acc: (f32, f32)) -> (f32, f32) { + if acc.0 > acc.1 { (0.0, 0.0) } else { acc } +} + +impl Peaks { + /// Build the overview of `samples` (one channel). NaN samples are ignored. + pub fn build(samples: &[f32]) -> Peaks { + let mut levels = Vec::new(); + let level0: Vec<(f32, f32)> = samples.chunks(BLOCK).map(|c| finish(c.iter().fold(EMPTY, |acc, &s| merge(acc, (s, s))))).collect(); + if level0.is_empty() { + return Peaks { len: 0, levels }; + } + levels.push(level0); + while let Some(last) = levels.last() { + if last.len() <= 1 { + break; + } + let next: Vec<(f32, f32)> = last.chunks(FANOUT).map(|c| c.iter().fold(EMPTY, |acc, &b| merge(acc, b))).collect(); + levels.push(next); + } + Peaks { len: samples.len(), levels } + } + + /// Number of samples the overview was built from. + pub fn len_samples(&self) -> usize { + self.len + } + + /// Number of resolution levels (0 for an empty overview). + pub fn num_levels(&self) -> usize { + self.levels.len() + } + + /// (min, max) over the sample range `[start, end)`, clamped to the data. Exact when both ends + /// are multiples of 32 (or `end` is the data length); otherwise the partial blocks at the edges + /// contribute their whole block's extremes, so the result always contains the true range. + /// Returns `(0.0, 0.0)` for an empty range. + pub fn minmax(&self, start: usize, end: usize) -> (f32, f32) { + let end = end.min(self.len); + if start >= end { + return (0.0, 0.0); + } + let mut lo = start / BLOCK; + let mut hi = end.div_ceil(BLOCK); + let mut acc = EMPTY; + let mut lvl = 0usize; + while lo < hi { + let Some(level) = self.levels.get(lvl) else { break }; + let has_parent = lvl + 1 < self.levels.len(); + if !has_parent || hi - lo < 2 * FANOUT { + for b in level.get(lo..hi.min(level.len())).unwrap_or_default() { + acc = merge(acc, *b); + } + break; + } + while !lo.is_multiple_of(FANOUT) && lo < hi { + acc = merge(acc, level.get(lo).copied().unwrap_or(EMPTY)); + lo += 1; + } + while !hi.is_multiple_of(FANOUT) && hi > lo { + hi -= 1; + acc = merge(acc, level.get(hi).copied().unwrap_or(EMPTY)); + } + lo /= FANOUT; + hi /= FANOUT; + lvl += 1; + } + finish(acc) + } + + /// Per-column (min, max) for drawing `cols` pixel columns spanning the sample range + /// `[start, end)`. Columns that fall outside the data are `(0.0, 0.0)`. Each column covers at + /// least one sample, so zoomed-in views repeat the containing sample's block. + pub fn columns(&self, start: f64, end: f64, cols: usize) -> Vec<(f32, f32)> { + let cols = cols.min(MAX_COLUMNS); + let mut out = vec![(0.0, 0.0); cols]; + if cols == 0 || !start.is_finite() || !end.is_finite() || end <= start || self.len == 0 { + return out; + } + let span = (end - start) / cols as f64; + let len = self.len as f64; + for (c, slot) in out.iter_mut().enumerate() { + let s = start + span * c as f64; + let e = start + span * (c + 1) as f64; + if e <= 0.0 || s >= len { + continue; + } + let si = s.max(0.0).floor(); + let ei = e.min(len).ceil().max(si + 1.0); + *slot = self.minmax(si as usize, ei as usize); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn brute(samples: &[f32], start: usize, end: usize) -> (f32, f32) { + let s = &samples[start..end]; + (s.iter().copied().fold(f32::INFINITY, f32::min), s.iter().copied().fold(f32::NEG_INFINITY, f32::max)) + } + + fn signal(n: usize) -> Vec { + (0..n).map(|i| ((i as f32 * 0.037).sin() * (i as f32 * 0.0011).cos()) + if i % 997 == 0 { 0.5 } else { 0.0 }).collect() + } + + #[test] + fn aligned_ranges_are_exact() { + let x = signal(100_003); + let p = Peaks::build(&x); + for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003)] { + assert_eq!(p.minmax(a, b), brute(&x, a, b), "range {a}..{b}"); + } + } + + #[test] + fn unaligned_ranges_contain_truth_and_match_block_cover() { + let x = signal(50_000); + let p = Peaks::build(&x); + let mut seed = 12345u64; + for _ in 0..2000 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let a = (seed >> 33) as usize % 50_000; + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let b = a + 1 + (seed >> 33) as usize % (50_000 - a); + let got = p.minmax(a, b); + let truth = brute(&x, a, b); + assert!(got.0 <= truth.0 && got.1 >= truth.1, "{a}..{b}: {got:?} vs {truth:?}"); + let cover = brute(&x, a / BLOCK * BLOCK, (b.div_ceil(BLOCK) * BLOCK).min(x.len())); + assert_eq!(got, cover, "{a}..{b}"); + } + } + + #[test] + fn degenerate_inputs() { + let p = Peaks::build(&[]); + assert_eq!(p.len_samples(), 0); + assert_eq!(p.minmax(0, 10), (0.0, 0.0)); + assert_eq!(p.columns(0.0, 10.0, 4), vec![(0.0, 0.0); 4]); + let p = Peaks::build(&[0.5, -0.25]); + assert_eq!(p.minmax(0, 2), (-0.25, 0.5)); + assert_eq!(p.minmax(5, 3), (0.0, 0.0)); + assert_eq!(p.minmax(0, usize::MAX), (-0.25, 0.5)); + assert!(p.columns(f64::NAN, 1.0, 3).iter().all(|&c| c == (0.0, 0.0))); + assert_eq!(p.columns(0.0, 1.0, 0).len(), 0); + let p = Peaks::build(&[f32::NAN, 1.0]); + assert_eq!(p.minmax(0, 2), (1.0, 1.0)); + } + + #[test] + fn columns_cover_range() { + let x = signal(10_000); + let p = Peaks::build(&x); + let cols = p.columns(0.0, 10_000.0, 100); + assert_eq!(cols.len(), 100); + for (c, &(lo, hi)) in cols.iter().enumerate() { + let truth = brute(&x, c * 100, c * 100 + 100); + assert!(lo <= truth.0 && hi >= truth.1); + } + let all = cols.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |a, &c| (a.0.min(c.0), a.1.max(c.1))); + assert_eq!(all, brute(&x, 0, 10_000)); + // Past the end of the data. + let cols = p.columns(9_000.0, 11_000.0, 2); + assert_eq!(cols[1], (0.0, 0.0)); + // Zoomed in further than one sample per column. + assert_eq!(p.columns(0.0, 4.0, 8).len(), 8); + } +} diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs new file mode 100644 index 00000000..8fa19166 --- /dev/null +++ b/crates/audio-io/src/symph.rs @@ -0,0 +1,223 @@ +//! Decode path for everything that isn't native PCM WAV/AIFF, backed by `symphonia`. + +use std::io::Cursor; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::{ + CODEC_TYPE_AAC, CODEC_TYPE_ADPCM_G722, CODEC_TYPE_ADPCM_G726, CODEC_TYPE_ADPCM_G726LE, CODEC_TYPE_ADPCM_IMA_QT, CODEC_TYPE_ADPCM_IMA_WAV, + CODEC_TYPE_ADPCM_MS, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP1, CODEC_TYPE_MP2, CODEC_TYPE_MP3, CODEC_TYPE_NULL, CODEC_TYPE_OPUS, + CODEC_TYPE_PCM_ALAW, CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE, CODEC_TYPE_PCM_MULAW, + CODEC_TYPE_VORBIS, CodecParameters, CodecType, DecoderOptions, +}; +use symphonia::core::errors::Error as SymError; +use symphonia::core::formats::{FormatOptions, FormatReader}; +use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions}; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +use crate::pcm::MAX_DECODE_SAMPLES; +use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat}; + +fn map_err(e: SymError) -> AudioError { + match e { + SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()), + SymError::LimitError(m) => AudioError::TooLarge(m.to_string()), + other => AudioError::Malformed(other.to_string()), + } +} + +/// Run `f`, turning a panic inside the third-party decoder into an error. +fn guarded(f: impl FnOnce() -> Result) -> Result { + catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into()))) +} + +fn open(bytes: &[u8], ext_hint: Option<&str>) -> Result> { + let mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); + let mut hint = Hint::new(); + if let Some(ext) = ext_hint { + hint.with_extension(ext.trim_start_matches('.')); + } + let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() }; + let probed = symphonia::default::get_probe().format(&hint, mss, &fmt_opts, &MetadataOptions::default()).map_err(map_err)?; + Ok(probed.format) +} + +fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat { + if detected != FileFormat::Other { + return detected; + } + match codec { + c if c == CODEC_TYPE_FLAC => FileFormat::Flac, + c if c == CODEC_TYPE_MP3 || c == CODEC_TYPE_MP2 || c == CODEC_TYPE_MP1 => FileFormat::Mp3, + c if c == CODEC_TYPE_AAC => FileFormat::Aac, + c if c == CODEC_TYPE_ALAC => FileFormat::Alac, + c if c == CODEC_TYPE_VORBIS => FileFormat::Ogg, + _ => FileFormat::Other, + } +} + +fn sample_format_of(p: &CodecParameters) -> SampleFormat { + let c = p.codec; + if c == CODEC_TYPE_PCM_F32LE || c == CODEC_TYPE_PCM_F32BE { + return SampleFormat::Float32; + } + if c == CODEC_TYPE_PCM_F64LE || c == CODEC_TYPE_PCM_F64BE { + return SampleFormat::Float64; + } + let lossy = [ + CODEC_TYPE_MP1, + CODEC_TYPE_MP2, + CODEC_TYPE_MP3, + CODEC_TYPE_AAC, + CODEC_TYPE_VORBIS, + CODEC_TYPE_OPUS, + CODEC_TYPE_ADPCM_MS, + CODEC_TYPE_ADPCM_IMA_WAV, + CODEC_TYPE_ADPCM_IMA_QT, + CODEC_TYPE_ADPCM_G722, + CODEC_TYPE_ADPCM_G726, + CODEC_TYPE_ADPCM_G726LE, + CODEC_TYPE_PCM_ALAW, + CODEC_TYPE_PCM_MULAW, + ]; + let lossless = !lossy.contains(&c); + match (lossless, p.bits_per_sample) { + (true, Some(1..=8)) => SampleFormat::Int8, + (true, Some(9..=16)) => SampleFormat::Int16, + (true, Some(17..=24)) => SampleFormat::Int24, + (true, Some(25..=32)) => SampleFormat::Int32, + _ => SampleFormat::Compressed, + } +} + +fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> { + let track = reader + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?; + let p = &track.codec_params; + let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?; + let channels = p.channels.map(|c| c.count()).unwrap_or(0); + let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?; + Ok(( + track.id, + AudioInfo { + format: format_from_codec(p.codec, detected), + sample_format: sample_format_of(p), + sample_rate, + channels, + frames: p.n_frames.unwrap_or(0), + bwf: None, + }, + )) +} + +/// Decode the whole first audio track. When `count_only`, samples are counted but not stored. +fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> { + let mut reader = open(bytes, ext_hint)?; + let (track_id, mut info) = info_of(reader.as_ref(), detected)?; + if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) { + let total = n.saturating_mul(u64::from(info.channels.max(1))); + if !count_only && total > MAX_DECODE_SAMPLES { + return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit"))); + } + } + let params = reader + .tracks() + .iter() + .find(|t| t.id == track_id) + .map(|t| t.codec_params.clone()) + .ok_or_else(|| AudioError::Malformed("track vanished".into()))?; + let mut decoder = symphonia::default::get_codecs().make(¶ms, &DecoderOptions::default()).map_err(map_err)?; + + let mut planar: Vec> = Vec::new(); + let mut frames: u64 = 0; + let mut sbuf: Option> = None; + let mut errors = 0usize; + loop { + let packet = match reader.next_packet() { + Ok(p) => p, + Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(SymError::ResetRequired) => break, + Err(e) => { + if frames == 0 { + return Err(map_err(e)); + } + log::warn!("stopping decode after demux error: {e}"); + break; + } + }; + if packet.track_id() != track_id { + continue; + } + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(SymError::DecodeError(m)) => { + errors += 1; + log::debug!("skipping corrupt packet: {m}"); + if errors > 1000 && frames == 0 { + return Err(AudioError::Malformed(format!("no decodable packets: {m}"))); + } + continue; + } + Err(SymError::IoError(_)) => break, + Err(e) => return Err(map_err(e)), + }; + let spec = *decoded.spec(); + let ch = spec.channels.count(); + if ch == 0 { + continue; + } + if planar.is_empty() { + // The decoded stream is authoritative for the channel count. + info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?; + planar = (0..ch).map(|_| Vec::new()).collect(); + } + let n = decoded.frames() as u64; + frames = frames.saturating_add(n); + if frames.saturating_mul(ch as u64) > MAX_DECODE_SAMPLES { + return Err(AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into())); + } + if count_only { + continue; + } + let needed = decoded.capacity() as u64; + let sb = match &mut sbuf { + Some(sb) if sb.capacity() >= decoded.capacity() * ch => sb, + _ => sbuf.insert(SampleBuffer::::new(needed, spec)), + }; + sb.copy_interleaved_ref(decoded); + for frame in sb.samples().chunks_exact(ch) { + for (dst, &s) in planar.iter_mut().zip(frame) { + dst.push(if s.is_finite() { s } else { 0.0 }); + } + } + } + if info.channels == 0 { + return Err(AudioError::Malformed("stream contains no audio".into())); + } + if planar.is_empty() { + planar = (0..usize::from(info.channels)).map(|_| Vec::new()).collect(); + } + info.frames = if count_only { frames } else { planar.iter().map(Vec::len).min().unwrap_or(0) as u64 }; + let sample_rate = info.sample_rate; + Ok((info, AudioBuffer { sample_rate, channels: planar })) +} + +pub(crate) fn probe(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result { + guarded(|| { + let reader = open(bytes, ext_hint)?; + let (_, info) = info_of(reader.as_ref(), detected)?; + if info.frames > 0 && info.channels > 0 { + return Ok(info); + } + // Length not in the header (e.g. MP3 without a Xing frame): count by decoding. + run(bytes, ext_hint, detected, true).map(|(i, _)| i) + }) +} + +pub(crate) fn decode(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> { + guarded(|| run(bytes, ext_hint, detected, false)) +} diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs new file mode 100644 index 00000000..102a5c39 --- /dev/null +++ b/crates/audio-io/src/wav.rs @@ -0,0 +1,326 @@ +//! Native WAV / BWF / RF64 reader and writer (RIFF chunk layout per the Microsoft multimedia +//! specification, `bext` per EBU Tech 3285, `ds64` per EBU Tech 3306). + +use crate::pcm::{PcmKind, Quantizer, clamp_float, deinterleave, le_u16, le_u32, le_u64, tag, validate_buffer}; +use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, BwfInfo, FileFormat, Result, SampleFormat}; + +const WAVE_FORMAT_PCM: u16 = 1; +const WAVE_FORMAT_IEEE_FLOAT: u16 = 3; +const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE; +/// Tail of the KSDATAFORMAT_SUBTYPE_* GUIDs (bytes 2..16); bytes 0..2 hold the format tag. +const GUID_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]; +const BEXT_MIN_LEN: usize = 602; + +/// Parsed WAV layout (no sample data decoded yet). +pub(crate) struct WavLayout { + pub info: AudioInfo, + pub kind: PcmKind, + pub data_start: usize, + pub data_len: usize, +} + +/// Outcome of parsing a WAV header. +pub(crate) enum WavParse { + /// A PCM / float layout we decode natively. + Native(WavLayout), + /// A valid WAV whose codec we hand to symphonia (ADPCM, A-law, ...). + Foreign { bwf: Option }, +} + +struct Fmt { + tag: u16, + channels: u16, + sample_rate: u32, + block_align: u16, + bits: u16, + valid_bits: u16, +} + +fn parse_fmt(c: &[u8]) -> Result { + let short = || AudioError::Malformed("fmt chunk too short".into()); + let mut fmt = Fmt { + tag: le_u16(c, 0).ok_or_else(short)?, + channels: le_u16(c, 2).ok_or_else(short)?, + sample_rate: le_u32(c, 4).ok_or_else(short)?, + block_align: le_u16(c, 12).ok_or_else(short)?, + bits: le_u16(c, 14).unwrap_or(0), + valid_bits: 0, + }; + fmt.valid_bits = fmt.bits; + if fmt.tag == WAVE_FORMAT_EXTENSIBLE { + // cbSize(2) validBits(2) channelMask(4) subFormat GUID(16) + if let (Some(valid), Some(sub)) = (le_u16(c, 18), le_u16(c, 24)) { + if valid != 0 { + fmt.valid_bits = valid; + } + fmt.tag = sub; + } else { + return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into())); + } + } + Ok(fmt) +} + +/// Read a NUL-padded ASCII field. +fn fixed_str(b: &[u8]) -> String { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim_end().to_string() +} + +fn parse_bext(c: &[u8]) -> Option { + if c.len() < 346 { + return None; + } + let lo = u64::from(le_u32(c, 338)?); + let hi = u64::from(le_u32(c, 342)?); + Some(BwfInfo { + description: fixed_str(c.get(0..256)?), + originator: fixed_str(c.get(256..288)?), + originator_reference: fixed_str(c.get(288..320)?), + origination_date: fixed_str(c.get(320..330)?), + origination_time: fixed_str(c.get(330..338)?), + time_reference: (hi << 32) | lo, + }) +} + +/// True when the bytes start like a RIFF/RF64/BW64 WAVE file. +pub(crate) fn is_wav(b: &[u8]) -> bool { + matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE") +} + +pub(crate) fn parse(b: &[u8]) -> Result { + if !is_wav(b) { + return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into())); + } + let is_rf64 = tag(b, 0).is_some_and(|t| &t != b"RIFF"); + let mut ds64_data: Option = None; + let mut fmt: Option = None; + let mut bwf: Option = None; + let mut data: Option<(usize, usize)> = None; + + let mut pos: usize = 12; + // Bound the number of chunks we look at so a file of tiny chunks can't spin forever. + let mut chunks = 0usize; + while let (Some(id), Some(size32)) = (tag(b, pos), le_u32(b, pos + 4)) { + chunks += 1; + if chunks > 100_000 { + break; + } + let body = pos.checked_add(8).ok_or_else(|| AudioError::Malformed("chunk offset overflow".into()))?; + let remaining = b.len().saturating_sub(body); + let mut size = u64::from(size32); + if &id == b"data" && is_rf64 && size32 == u32::MAX { + size = ds64_data.unwrap_or(remaining as u64); + } + let declared = usize::try_from(size).unwrap_or(usize::MAX); + if &id == b"data" { + // Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still + // yields whatever audio is actually present. + let len = if size32 == 0 && !is_rf64 { remaining } else { declared.min(remaining) }; + if data.is_none() { + data = Some((body, len)); + } + if declared > remaining { + break; + } + } else { + if declared > remaining { + // A truncated non-audio chunk at the end of the file: stop scanning. + break; + } + let c = b.get(body..body + declared).unwrap_or_default(); + match &id { + b"fmt " => fmt = Some(parse_fmt(c)?), + b"bext" => bwf = parse_bext(c), + b"ds64" => ds64_data = le_u64(c, 8), + _ => {} + } + } + let padded = declared.saturating_add(declared & 1); + pos = match body.checked_add(padded) { + Some(p) => p, + None => break, + }; + } + + let fmt = fmt.ok_or_else(|| AudioError::Malformed("WAV has no fmt chunk".into()))?; + if fmt.channels == 0 { + return Err(AudioError::Malformed("WAV declares zero channels".into())); + } + if fmt.sample_rate == 0 { + return Err(AudioError::Malformed("WAV declares a zero sample rate".into())); + } + let channels = usize::from(fmt.channels); + let (kind, sample_format) = match (fmt.tag, fmt.bits) { + (WAVE_FORMAT_PCM, bits @ 1..=32) => { + let bytes = usize::from(bits.div_ceil(8)); + let sf = match fmt.valid_bits.min(bits) { + 0..=8 => SampleFormat::Int8, + 9..=16 => SampleFormat::Int16, + 17..=24 => SampleFormat::Int24, + _ => SampleFormat::Int32, + }; + (PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf) + } + (WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32), + (WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64), + (WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => { + return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag))); + } + _ => return Ok(WavParse::Foreign { bwf }), + }; + let block = kind.bytes().checked_mul(channels).ok_or_else(|| AudioError::Malformed("block size overflow".into()))?; + if usize::from(fmt.block_align) != block { + log::debug!("WAV block_align {} disagrees with computed {block}; using computed", fmt.block_align); + } + let (data_start, data_len) = data.ok_or_else(|| AudioError::Malformed("WAV has no data chunk".into()))?; + let frames = (data_len / block) as u64; + Ok(WavParse::Native(WavLayout { + info: AudioInfo { format: FileFormat::Wav, sample_format, sample_rate: fmt.sample_rate, channels: fmt.channels, frames, bwf }, + kind, + data_start, + data_len, + })) +} + +pub(crate) fn decode(b: &[u8], layout: &WavLayout) -> Result { + let end = layout.data_start.saturating_add(layout.data_len).min(b.len()); + let data = b.get(layout.data_start..end).unwrap_or_default(); + let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?; + Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels }) +} + +fn channel_mask(channels: usize) -> u32 { + match channels { + 1 => 0x4, + 2 => 0x3, + 3 => 0x7, + 4 => 0x33, + 5 => 0x37, + 6 => 0x3F, + 7 => 0x13F, + 8 => 0x63F, + n if n <= 18 => (1u32 << n) - 1, + _ => 0, + } +} + +fn put_fixed_str(out: &mut Vec, s: &str, len: usize) { + let bytes = s.as_bytes(); + let n = bytes.len().min(len); + out.extend_from_slice(bytes.get(..n).unwrap_or_default()); + out.resize(out.len() + (len - n), 0); +} + +fn bext_chunk(bwf: &BwfInfo) -> Vec { + let mut c = Vec::with_capacity(BEXT_MIN_LEN); + put_fixed_str(&mut c, &bwf.description, 256); + put_fixed_str(&mut c, &bwf.originator, 32); + put_fixed_str(&mut c, &bwf.originator_reference, 32); + put_fixed_str(&mut c, &bwf.origination_date, 10); + put_fixed_str(&mut c, &bwf.origination_time, 8); + c.extend_from_slice(&((bwf.time_reference & 0xFFFF_FFFF) as u32).to_le_bytes()); + c.extend_from_slice(&((bwf.time_reference >> 32) as u32).to_le_bytes()); + c.extend_from_slice(&1u16.to_le_bytes()); // version 1 + c.resize(BEXT_MIN_LEN, 0); // UMID, loudness, reserved; no coding history + c +} + +fn push_chunk(out: &mut Vec, id: &[u8; 4], body: &[u8]) { + out.extend_from_slice(id); + out.extend_from_slice(&u32::try_from(body.len()).unwrap_or(u32::MAX).to_le_bytes()); + out.extend_from_slice(body); + if body.len() % 2 == 1 { + out.push(0); + } +} + +/// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests); +/// it is used automatically when the file would exceed 4 GiB. +pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool, bwf: Option<&BwfInfo>, force_rf64: bool) -> Result> { + validate_buffer(buf)?; + let channels = buf.channels.len(); + let frames = buf.frames(); + let (bits, is_float): (u16, bool) = match depth { + BitDepth::Int16 => (16, false), + BitDepth::Int24 => (24, false), + BitDepth::Int32 => (32, false), + BitDepth::Float32 => (32, true), + }; + let bytes = usize::from(bits / 8); + let block = bytes.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 data_len = block.checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; + let extensible = channels > 2 || bits > 16; + let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM }; + + let mut fmt = Vec::with_capacity(40); + 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(&buf.sample_rate.to_le_bytes()); + fmt.extend_from_slice(&buf.sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes()); + fmt.extend_from_slice(&block_align.to_le_bytes()); + fmt.extend_from_slice(&bits.to_le_bytes()); + if extensible { + fmt.extend_from_slice(&22u16.to_le_bytes()); + fmt.extend_from_slice(&bits.to_le_bytes()); + fmt.extend_from_slice(&channel_mask(channels).to_le_bytes()); + fmt.extend_from_slice(&fmt_tag.to_le_bytes()); + fmt.extend_from_slice(&GUID_TAIL); + } + + let mut head = Vec::with_capacity(1024); + let rf64_body_len = 28usize; + // Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place). + let mut meta = Vec::new(); + if let Some(bwf) = bwf { + push_chunk(&mut meta, b"bext", &bext_chunk(bwf)); + } + push_chunk(&mut meta, b"fmt ", &fmt); + if is_float { + push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes()); + } + let total = 4 + (8 + rf64_body_len) + meta.len() + 8 + data_len + (data_len & 1); + let rf64 = force_rf64 || u32::try_from(total).is_err(); + + if rf64 { + head.extend_from_slice(b"RF64"); + head.extend_from_slice(&u32::MAX.to_le_bytes()); + head.extend_from_slice(b"WAVE"); + let mut ds64 = Vec::with_capacity(rf64_body_len); + ds64.extend_from_slice(&(total as u64).to_le_bytes()); + ds64.extend_from_slice(&(data_len as u64).to_le_bytes()); + ds64.extend_from_slice(&(frames as u64).to_le_bytes()); + ds64.extend_from_slice(&0u32.to_le_bytes()); + push_chunk(&mut head, b"ds64", &ds64); + } else { + head.extend_from_slice(b"RIFF"); + head.extend_from_slice(&(total as u32).to_le_bytes()); + head.extend_from_slice(b"WAVE"); + push_chunk(&mut head, b"JUNK", &[0u8; 28]); + } + head.extend_from_slice(&meta); + head.extend_from_slice(b"data"); + head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes()); + + let mut out = Vec::new(); + out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1)) + .map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?; + out.extend_from_slice(&head); + let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels); + for f in 0..frames { + for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) { + let s = ch.get(f).copied().unwrap_or(0.0); + if is_float { + out.extend_from_slice(&clamp_float(s).to_le_bytes()); + } else { + let v = q.quantize(s).to_le_bytes(); + out.extend_from_slice(v.get(..bytes).unwrap_or_default()); + } + } + } + if data_len % 2 == 1 { + out.push(0); + } + Ok(out) +} diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs new file mode 100644 index 00000000..1cec6569 --- /dev/null +++ b/crates/audio-io/tests/malformed.rs @@ -0,0 +1,298 @@ +//! Hostile inputs must produce `Err` (or a best-effort `Ok`), never a panic. + +use proptest::prelude::*; +use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, probe}; + +fn small(format: FileFormat, depth: BitDepth, channels: usize) -> Vec { + let mut b = AudioBuffer::new(44_100, channels, 300); + for ch in &mut b.channels { + for (i, s) in ch.iter_mut().enumerate() { + *s = ((i as f32) * 0.1).sin() * 0.5; + } + } + encode(&b, &EncodeOptions { format, bit_depth: depth, dither: false, bwf: None }).unwrap() +} + +/// Little helper for building RIFF files by hand. +fn riff(chunks: &[(&[u8; 4], Vec)]) -> Vec { + let mut body = b"WAVE".to_vec(); + for (id, data) in chunks { + body.extend_from_slice(*id); + body.extend_from_slice(&(data.len() as u32).to_le_bytes()); + body.extend_from_slice(data); + if data.len() % 2 == 1 { + body.push(0); + } + } + let mut out = b"RIFF".to_vec(); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend_from_slice(&body); + out +} + +fn fmt(tag: u16, channels: u16, sr: u32, bits: u16) -> Vec { + let align = channels * bits.div_ceil(8); + let mut f = Vec::new(); + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&channels.to_le_bytes()); + f.extend_from_slice(&sr.to_le_bytes()); + f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes()); + f.extend_from_slice(&align.to_le_bytes()); + f.extend_from_slice(&bits.to_le_bytes()); + f +} + +#[test] +fn every_truncation_is_handled() { + let files = [ + small(FileFormat::Wav, BitDepth::Int16, 2), + small(FileFormat::Wav, BitDepth::Float32, 6), + small(FileFormat::Aiff, BitDepth::Int24, 1), + small(FileFormat::Aiff, BitDepth::Float32, 2), + small(FileFormat::Flac, BitDepth::Int16, 2), + small(FileFormat::Flac, BitDepth::Int24, 1), + ]; + for file in &files { + for n in 0..file.len() { + let cut = &file[..n]; + let _ = probe(cut, None); + if let Ok((info, buf)) = decode(cut, None) { + assert_eq!(buf.num_channels(), info.channels as usize); + assert_eq!(buf.frames() as u64, info.frames); + } + } + } +} + +#[test] +fn truncated_data_chunk_yields_partial_audio() { + let file = small(FileFormat::Wav, BitDepth::Int16, 2); + let (info, buf) = decode(&file[..file.len() - 400], None).unwrap(); + assert_eq!(info.frames, 200); + assert_eq!(buf.frames(), 200); +} + +#[test] +fn every_single_byte_corruption_is_handled() { + for file in [small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Aiff, BitDepth::Int16, 2), small(FileFormat::Flac, BitDepth::Int16, 1)] + { + for i in 0..file.len().min(200) { + for v in [0x00, 0xFF, 0x7F, 0x80] { + let mut f = file.clone(); + f[i] = v; + let _ = probe(&f, None); + let _ = decode(&f, None); + } + } + } +} + +#[test] +fn zero_channels_is_malformed() { + let wav = riff(&[(b"fmt ", fmt(1, 0, 44_100, 16)), (b"data", vec![0; 16])]); + assert!(matches!(decode(&wav, None), Err(AudioError::Malformed(_)))); + assert!(probe(&wav, None).is_err()); + let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1); + let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap(); + aiff[comm + 8] = 0; + aiff[comm + 9] = 0; + assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_)))); +} + +#[test] +fn zero_sample_rate_is_malformed() { + let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]); + assert!(decode(&wav, None).is_err()); + let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1); + let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap(); + for b in &mut aiff[comm + 16..comm + 26] { + *b = 0; + } + assert!(decode(&aiff, None).is_err()); + // Infinite exponent. + aiff[comm + 16] = 0x7F; + aiff[comm + 17] = 0xFF; + assert!(decode(&aiff, None).is_err()); +} + +#[test] +fn absurd_chunk_sizes() { + // fmt chunk claims 4 GiB. + let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]); + wav[16..20].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(decode(&wav, None).is_err()); + // data chunk claims 4 GiB: lenient read of what's there. + let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]); + let d = wav.windows(4).position(|w| w == b"data").unwrap(); + wav[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes()); + let (info, buf) = decode(&wav, None).unwrap(); + assert_eq!((info.frames, buf.frames()), (4, 4)); + // An unknown chunk with an absurd size before fmt. + let mut wav = riff(&[(b"junk", vec![0; 4]), (b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]); + wav[16..20].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes()); + assert!(decode(&wav, None).is_err()); + // RF64 with a ds64 claiming an exabyte of data. + let mut ds64 = Vec::new(); + ds64.extend_from_slice(&u64::MAX.to_le_bytes()); + ds64.extend_from_slice(&u64::MAX.to_le_bytes()); + ds64.extend_from_slice(&u64::MAX.to_le_bytes()); + ds64.extend_from_slice(&0u32.to_le_bytes()); + let mut rf = riff(&[(b"ds64", ds64), (b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80; 10])]); + rf[..4].copy_from_slice(b"RF64"); + let d = rf.windows(4).rposition(|w| w == b"data").unwrap(); + rf[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes()); + let (info, buf) = decode(&rf, None).unwrap(); + assert_eq!((info.frames, buf.frames()), (10, 10)); + // AIFF COMM claims more frames than present; SSND offset beyond the end. + let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1); + let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap(); + aiff[comm + 10..comm + 14].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(decode(&aiff, None).unwrap().1.frames(), 300); + let ssnd = aiff.windows(4).position(|w| w == b"SSND").unwrap(); + aiff[ssnd + 8..ssnd + 12].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0); +} + +#[test] +fn many_tiny_chunks_terminate() { + let mut chunks: Vec<(&[u8; 4], Vec)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))]; + for _ in 0..200_000 { + chunks.push((b"pad ", Vec::new())); + } + let wav = riff(&chunks); + assert!(decode(&wav, None).is_err()); // no data chunk within the scan limit +} + +#[test] +fn hand_built_wav_variants() { + // 8-bit unsigned. + let wav = riff(&[(b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80, 0xFF, 0x00])]); + let (info, buf) = decode(&wav, None).unwrap(); + assert_eq!(info.sample_format, SampleFormat::Int8); + assert_eq!(buf.channels[0], vec![0.0, 127.0 / 128.0, -1.0]); + // 64-bit float, with NaN replaced by silence. + let mut data = Vec::new(); + for v in [0.25f64, -0.5, f64::NAN] { + data.extend_from_slice(&v.to_le_bytes()); + } + let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 64)), (b"data", data)]); + let (info, buf) = decode(&wav, None).unwrap(); + assert_eq!(info.sample_format, SampleFormat::Float64); + assert_eq!(buf.channels[0], vec![0.25, -0.5, 0.0]); + // Unsupported PCM width. + let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 16)), (b"data", vec![0; 4])]); + assert!(matches!(decode(&wav, None), Err(AudioError::Unsupported(_)))); + // No data chunk / no fmt chunk. + assert!(decode(&riff(&[(b"fmt ", fmt(1, 1, 8_000, 16))]), None).is_err()); + assert!(decode(&riff(&[(b"data", vec![0; 4])]), None).is_err()); + // Short fmt chunk. + assert!(decode(&riff(&[(b"fmt ", vec![1, 0, 1]), (b"data", vec![0; 4])]), None).is_err()); + // Unknown codec tag goes to symphonia and fails cleanly. + let wav = riff(&[(b"fmt ", fmt(0x1234, 1, 8_000, 16)), (b"data", vec![0; 4])]); + assert!(decode(&wav, None).is_err()); +} + +#[test] +fn aiff_sowt_little_endian() { + let be = small(FileFormat::Aiff, BitDepth::Int16, 2); + let (_, expect) = decode(&be, None).unwrap(); + // Rebuild as AIFC/sowt by hand. + let comm = be.windows(4).position(|w| w == b"COMM").unwrap(); + let ssnd = be.windows(4).position(|w| w == b"SSND").unwrap(); + let mut comm_body = be[comm + 8..comm + 26].to_vec(); + comm_body.extend_from_slice(b"sowt"); + comm_body.extend_from_slice(&[0, 0]); // empty pstring, padded + let mut ssnd_body = be[ssnd + 8..].to_vec(); + for pair in ssnd_body[8..].as_chunks_mut::<2>().0 { + pair.swap(0, 1); + } + let mut body = b"AIFC".to_vec(); + for (id, data) in [(b"COMM", &comm_body), (b"SSND", &ssnd_body)] { + body.extend_from_slice(id); + body.extend_from_slice(&(data.len() as u32).to_be_bytes()); + body.extend_from_slice(data); + } + let mut f = b"FORM".to_vec(); + f.extend_from_slice(&(body.len() as u32).to_be_bytes()); + f.extend_from_slice(&body); + let (info, got) = decode(&f, None).unwrap(); + assert_eq!(info.sample_format, SampleFormat::Int16); + assert_eq!(got, expect); +} + +#[test] +fn detect_format_by_magic_and_hint() { + assert_eq!(detect_format(&small(FileFormat::Wav, BitDepth::Int16, 1), Some("mp3")), FileFormat::Wav); + assert_eq!(detect_format(&small(FileFormat::Aiff, BitDepth::Int16, 1), None), FileFormat::Aiff); + assert_eq!(detect_format(&small(FileFormat::Flac, BitDepth::Int16, 1), None), FileFormat::Flac); + assert_eq!(detect_format(b"OggS\0\x02rest", None), FileFormat::Ogg); + assert_eq!(detect_format(b"caff\0\x01\0\0", None), FileFormat::Caf); + assert_eq!(detect_format(b"ID3\x04\0\0\0\0\0\x00", None), FileFormat::Mp3); + let mut id3_flac = b"ID3\x04\0\0\0\0\0\x02xx".to_vec(); + id3_flac.extend_from_slice(b"fLaC"); + assert_eq!(detect_format(&id3_flac, None), FileFormat::Flac); + assert_eq!(detect_format(&[0xFF, 0xFB, 0x90, 0x00], None), FileFormat::Mp3); + assert_eq!(detect_format(&[0xFF, 0xF1, 0x50, 0x80], None), FileFormat::Aac); + let mut m4a = b"\0\0\0\x20ftypM4A ".to_vec(); + m4a.extend_from_slice(b"....stsd\0\0\0\0\0\0\0\x01\0\0\0\x24alac"); + assert_eq!(detect_format(&m4a, None), FileFormat::Alac); + assert_eq!(detect_format(b"\0\0\0\x20ftypM4A ", None), FileFormat::Aac); + assert_eq!(detect_format(b"", Some(".WAV")), FileFormat::Wav); + assert_eq!(detect_format(b"zz", Some("aif")), FileFormat::Aiff); + assert_eq!(detect_format(b"zz", Some("m4a")), FileFormat::Aac); + assert_eq!(detect_format(b"zz", Some("flac")), FileFormat::Flac); + assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other); + assert_eq!(detect_format(b"zz", None), FileFormat::Other); + assert_eq!(detect_format(b"", None), FileFormat::Other); +} + +#[test] +fn garbage_in_compressed_containers_errors() { + let garbage: Vec = (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect(); + for hint in [None, Some("mp3"), Some("ogg"), Some("flac"), Some("m4a"), Some("caf"), Some("aac"), Some("mkv"), Some("wav"), Some("aiff")] { + assert!(decode(&garbage, hint).is_err(), "{hint:?}"); + assert!(probe(&garbage, hint).is_err(), "{hint:?}"); + } + for magic in [&b"fLaC"[..], b"OggS", b"caff", b"ID3\x03\0\0\0\0\0\0", &[0xFF, 0xFB, 0x90, 0x00], b"\0\0\0\x20ftypM4A "] { + let mut f = magic.to_vec(); + f.extend_from_slice(&garbage); + let _ = probe(&f, None); + assert!(decode(&f, None).map(|(_, b)| b.frames()).unwrap_or(0) < 1_000_000); + } + assert!(decode(&[], None).is_err()); +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] + + #[test] + fn random_bytes_never_panic(bytes in proptest::collection::vec(any::(), 0..2048), hint in 0usize..6) { + let hint = [None, Some("wav"), Some("aiff"), Some("flac"), Some("mp3"), Some("ogg")][hint]; + let _ = detect_format(&bytes, hint); + let _ = probe(&bytes, hint); + let _ = decode(&bytes, hint); + } + + #[test] + fn random_bodies_behind_valid_headers_never_panic(body in proptest::collection::vec(any::(), 0..1024), which in 0usize..4) { + let mut f = match which { + 0 => b"RIFF\xff\xff\xff\xffWAVE".to_vec(), + 1 => b"RF64\xff\xff\xff\xffWAVE".to_vec(), + 2 => b"FORM\xff\xff\xff\xffAIFC".to_vec(), + _ => b"FORM\xff\xff\xff\xffAIFF".to_vec(), + }; + f.extend_from_slice(&body); + let _ = probe(&f, None); + let _ = decode(&f, None); + } + + #[test] + fn random_audio_roundtrips_through_flac(samples in proptest::collection::vec(-1.2f32..1.2, 1..3000), channels in 1usize..4) { + let buf = AudioBuffer::from_interleaved(48_000, channels, &samples); + prop_assume!(buf.frames() > 0); + let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int24, dither: false, bwf: None }; + let (_, flac) = decode(&encode(&buf, &opts).unwrap(), None).unwrap(); + let (_, wav) = decode(&encode(&buf, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap(), None).unwrap(); + prop_assert_eq!(flac, wav); + } +} diff --git a/crates/audio-io/tests/roundtrip.rs b/crates/audio-io/tests/roundtrip.rs new file mode 100644 index 00000000..d9b1571e --- /dev/null +++ b/crates/audio-io/tests/roundtrip.rs @@ -0,0 +1,250 @@ +use soundcraft_audio_io::{ + AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, +}; + +fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer { + let mut b = AudioBuffer::new(sr, channels, frames); + for (c, ch) in b.channels.iter_mut().enumerate() { + let f = 220.0 * (c as f32 + 1.0); + for (i, s) in ch.iter_mut().enumerate() { + *s = 0.8 * (2.0 * std::f32::consts::PI * f * i as f32 / sr as f32 + c as f32).sin(); + } + } + b +} + +fn tolerance(depth: BitDepth, dither: bool) -> f32 { + let lsb = match depth { + BitDepth::Int16 => 1.0 / 32768.0, + BitDepth::Int24 => 1.0 / 8_388_608.0, + BitDepth::Int32 => 1e-7, + BitDepth::Float32 => 0.0, + }; + lsb * if dither { 1.6 } else { 0.6 } + 1e-7 +} + +fn max_err(a: &AudioBuffer, b: &AudioBuffer) -> f32 { + assert_eq!(a.num_channels(), b.num_channels()); + assert_eq!(a.frames(), b.frames()); + a.channels.iter().zip(&b.channels).flat_map(|(x, y)| x.iter().zip(y).map(|(p, q)| (p - q).abs())).fold(0.0, f32::max) +} + +const DEPTHS: [BitDepth; 4] = [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32, BitDepth::Float32]; + +fn expected_sf(d: BitDepth) -> SampleFormat { + match d { + BitDepth::Int16 => SampleFormat::Int16, + BitDepth::Int24 => SampleFormat::Int24, + BitDepth::Int32 => SampleFormat::Int32, + BitDepth::Float32 => SampleFormat::Float32, + } +} + +#[test] +fn wav_and_aiff_roundtrip_all_depths_and_layouts() { + for format in [FileFormat::Wav, FileFormat::Aiff] { + for depth in DEPTHS { + for channels in [1, 2, 6] { + for dither in [false, true] { + let src = sine(48_000, channels, 4801); + let opts = EncodeOptions { format, bit_depth: depth, dither, bwf: None }; + let bytes = encode(&src, &opts).unwrap(); + assert_eq!(detect_format(&bytes, None), format); + let info = probe(&bytes, None).unwrap(); + assert_eq!(info.format, format); + assert_eq!(info.sample_rate, 48_000); + assert_eq!(info.channels as usize, channels); + assert_eq!(info.frames, 4801); + assert_eq!(info.sample_format, expected_sf(depth)); + let (dinfo, out) = decode(&bytes, None).unwrap(); + assert_eq!(dinfo, info); + assert_eq!(out.sample_rate, 48_000); + let err = max_err(&src, &out); + assert!(err <= tolerance(depth, dither), "{format:?} {depth:?} {channels}ch dither={dither}: err {err}"); + } + } + } + } +} + +#[test] +fn flac_roundtrip_is_lossless_vs_wav() { + for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] { + for channels in [1, 2, 6] { + // Spans several blocks plus a short final block. + let src = sine(44_100, channels, 4096 * 2 + 17); + let wav = encode(&src, &EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: None }).unwrap(); + let flac = encode(&src, &EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither: false, bwf: None }).unwrap(); + assert_eq!(detect_format(&flac, None), FileFormat::Flac); + let info = probe(&flac, None).unwrap(); + assert_eq!(info.format, FileFormat::Flac); + assert_eq!((info.sample_rate, info.channels as usize, info.frames), (44_100, channels, src.frames() as u64)); + assert_eq!(info.sample_format, expected_sf(depth)); + let (_, from_wav) = decode(&wav, None).unwrap(); + let (finfo, from_flac) = decode(&flac, None).unwrap(); + assert_eq!(finfo.frames, src.frames() as u64); + assert_eq!(from_flac, from_wav, "{depth:?} {channels}ch"); + if depth != BitDepth::Int32 { + assert!(flac.len() < wav.len(), "{depth:?} {channels}ch: flac {} >= wav {}", flac.len(), wav.len()); + } + } + } +} + +#[test] +fn flac_handles_silence_noise_full_scale_and_odd_rates() { + let frames = 10_000; + let mut b = AudioBuffer::new(37_123, 3, frames); + let mut seed = 1u32; + for i in 0..frames { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + b.channels[1][i] = (seed as f32 / u32::MAX as f32) * 2.0 - 1.0; // white noise + b.channels[2][i] = if i % 2 == 0 { 1.5 } else { -1.5 }; // clipped full-scale square + } + for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] { + for dither in [false, true] { + let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither, bwf: None }; + let flac = encode(&b, &opts).unwrap(); + let (info, out) = decode(&flac, None).unwrap(); + assert_eq!(info.sample_rate, 37_123); + let wav = encode(&b, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap(); + let (_, expect) = decode(&wav, None).unwrap(); + assert_eq!(out, expect, "{depth:?} dither={dither}"); + } + } +} + +#[test] +fn flac_rejects_unsupported_layouts() { + let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Float32, dither: false, bwf: None }; + assert!(encode(&sine(48_000, 2, 10), &opts).is_err()); + let opts = EncodeOptions { bit_depth: BitDepth::Int16, ..opts }; + assert!(encode(&sine(48_000, 9, 10), &opts).is_err()); + assert!(encode(&sine(700_000, 1, 10), &opts).is_err()); + // Tiny buffers still encode and decode. + let one = sine(48_000, 2, 1); + let (_, out) = decode(&encode(&one, &opts).unwrap(), None).unwrap(); + assert_eq!(out.frames(), 1); +} + +#[test] +fn bwf_roundtrip() { + let bwf = BwfInfo { + description: "Scene 12 take 3".into(), + originator: "SoundCraft".into(), + originator_reference: "SC-0001".into(), + origination_date: "2026-10-06".into(), + origination_time: "12:34:56".into(), + time_reference: 0x1_2345_6789, // > 32 bits + }; + let src = sine(96_000, 2, 1000); + for depth in DEPTHS { + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: Some(bwf.clone()) }; + let bytes = encode(&src, &opts).unwrap(); + let info = probe(&bytes, None).unwrap(); + assert_eq!(info.bwf.as_ref(), Some(&bwf)); + let (dinfo, _) = decode(&bytes, None).unwrap(); + assert_eq!(dinfo.bwf, Some(bwf.clone())); + // RF64 layout reads back identically. + let rf64 = encode_wav_rf64(&src, &opts).unwrap(); + assert_eq!(&rf64[..4], b"RF64"); + let (rinfo, rbuf) = decode(&rf64, None).unwrap(); + assert_eq!(rinfo.bwf, Some(bwf.clone())); + assert_eq!(rinfo.frames, 1000); + assert_eq!(rbuf, decode(&bytes, None).unwrap().1); + } + // Overlong strings are truncated to the field width, not an error. + let long = BwfInfo { description: "x".repeat(400), ..BwfInfo::default() }; + let bytes = encode(&src, &EncodeOptions { bwf: Some(long), ..EncodeOptions::default() }).unwrap(); + assert_eq!(probe(&bytes, None).unwrap().bwf.unwrap().description.len(), 256); +} + +#[test] +fn wav_header_layout() { + let b = encode(&sine(44_100, 2, 10), &EncodeOptions { bit_depth: BitDepth::Int16, ..EncodeOptions::default() }).unwrap(); + // Plain PCM for 16-bit stereo; extensible otherwise. + let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap(); + assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 1); + let b = encode(&sine(44_100, 2, 10), &EncodeOptions::default()).unwrap(); + let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap(); + assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 0xFFFE); + let riff = u32::from_le_bytes([b[4], b[5], b[6], b[7]]) as usize; + assert_eq!(riff + 8, b.len()); + // Odd data length (24-bit mono, odd frames) gets a pad byte. + let b = encode(&sine(44_100, 1, 3), &EncodeOptions::default()).unwrap(); + assert_eq!(b.len() % 2, 0); + assert_eq!(decode(&b, None).unwrap().1.frames(), 3); +} + +#[test] +fn aiff_float_is_aifc() { + let b = + encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap(); + assert_eq!(&b[8..12], b"AIFC"); + assert!(b.windows(4).any(|w| w == b"fl32")); + let b = encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, ..EncodeOptions::default() }).unwrap(); + assert_eq!(&b[8..12], b"AIFF"); + let form = u32::from_be_bytes([b[4], b[5], b[6], b[7]]) as usize; + assert_eq!(form + 8, b.len()); +} + +#[test] +fn unusual_sample_rates_roundtrip() { + for sr in [1, 8_000, 11_025, 22_050, 44_100, 88_200, 176_400, 192_000, 352_800, 384_000, 768_000] { + for format in [FileFormat::Wav, FileFormat::Aiff] { + let bytes = encode(&sine(sr, 1, 8), &EncodeOptions { format, ..EncodeOptions::default() }).unwrap(); + assert_eq!(probe(&bytes, None).unwrap().sample_rate, sr, "{format:?}"); + } + } +} + +#[test] +fn encode_rejects_bad_buffers_and_formats() { + assert!(encode(&AudioBuffer::default(), &EncodeOptions::default()).is_err()); + assert!(encode(&AudioBuffer::new(0, 2, 10), &EncodeOptions::default()).is_err()); + for f in [FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Alac, FileFormat::Caf, FileFormat::Other] { + assert!(encode(&sine(48_000, 1, 10), &EncodeOptions { format: f, ..EncodeOptions::default() }).is_err()); + } + // Non-finite and out-of-range samples are clamped, not errors. + let mut b = AudioBuffer::new(48_000, 1, 4); + b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5]; + for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] { + for depth in DEPTHS { + if format == FileFormat::Flac && depth == BitDepth::Float32 { + continue; + } + let bytes = encode(&b, &EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() }).unwrap(); + let (_, out) = decode(&bytes, None).unwrap(); + assert_eq!(out.channels[0][0], 0.0); + assert!(out.channels[0][1] > 0.99 && out.channels[0][1] <= 1.0); + assert_eq!(out.channels[0][2], -1.0); + } + } + // Ragged buffers encode the common length. + let ragged = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10], vec![0.2; 7]] }; + let (_, out) = decode(&encode(&ragged, &EncodeOptions::default()).unwrap(), None).unwrap(); + assert_eq!(out.frames(), 7); +} + +#[test] +fn audio_buffer_helpers() { + let b = AudioBuffer::from_interleaved(48_000, 2, &[0.1, -0.2, 0.3, -0.4, 0.5]); + assert_eq!(b.frames(), 2); + assert_eq!(b.num_channels(), 2); + assert_eq!(b.interleaved(), vec![0.1, -0.2, 0.3, -0.4]); + assert!((b.peak() - 0.4).abs() < 1e-7); + assert!((b.duration_secs() - 2.0 / 48_000.0).abs() < 1e-12); + assert_eq!(AudioBuffer::from_interleaved(1, 0, &[1.0]).num_channels(), 0); + assert_eq!(AudioBuffer::new(0, 1, 5).duration_secs(), 0.0); + assert_eq!(AudioBuffer::default().frames(), 0); +} + +#[test] +fn serde_types_roundtrip() { + let opts = EncodeOptions { bwf: Some(BwfInfo { time_reference: 42, ..BwfInfo::default() }), ..EncodeOptions::default() }; + let s = serde_json::to_string(&opts).unwrap(); + assert_eq!(serde_json::from_str::(&s).unwrap(), opts); + let info = probe(&encode(&sine(48_000, 1, 4), &opts).unwrap(), None).unwrap(); + let s = serde_json::to_string(&info).unwrap(); + assert_eq!(serde_json::from_str::(&s).unwrap(), info); +} diff --git a/crates/dsp/Cargo.toml b/crates/dsp/Cargo.toml new file mode 100644 index 00000000..3429a9ad --- /dev/null +++ b/crates/dsp/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "soundcraft-dsp" +description = "SoundCraft DSP: realtime plugins (EQ, dynamics, reverb, delay, modulation, instruments), meters, loudness, and offline AudioSuite-style processing" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +log.workspace = true +rustfft.workspace = true + +[dev-dependencies] +proptest.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/dsp/src/biquad.rs b/crates/dsp/src/biquad.rs new file mode 100644 index 00000000..bc0c7db7 --- /dev/null +++ b/crates/dsp/src/biquad.rs @@ -0,0 +1,208 @@ +//! Second-order IIR filters designed from the RBJ "Audio EQ Cookbook" formulas, plus first-order +//! low/high-pass sections (bilinear transform) for 6 dB/oct slopes. + +use std::f64::consts::PI; + +use crate::util::{flush, sane_sr}; + +/// Filter response shapes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum FilterType { + LowPass, + HighPass, + BandPass, + Notch, + Peak, + LowShelf, + HighShelf, + AllPass, + /// First-order (6 dB/oct) low-pass; `q` and `gain_db` are ignored. + LowPass1, + /// First-order (6 dB/oct) high-pass; `q` and `gain_db` are ignored. + HighPass1, +} + +/// Normalized biquad coefficients (`a0 = 1`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Coeffs { + pub b0: f32, + pub b1: f32, + pub b2: f32, + pub a1: f32, + pub a2: f32, +} + +impl Default for Coeffs { + fn default() -> Self { + Self::IDENTITY + } +} + +impl Coeffs { + /// Passes the signal unchanged. + pub const IDENTITY: Coeffs = Coeffs { b0: 1.0, b1: 0.0, b2: 0.0, a1: 0.0, a2: 0.0 }; + + /// Designs a filter. All inputs are sanitized: frequency is clamped to 1 Hz .. 0.49·sr, + /// Q to 0.025 .. 100 and gain to ±48 dB. + pub fn design(kind: FilterType, freq: f32, q: f32, gain_db: f32, sample_rate: f32) -> Coeffs { + let sr = f64::from(sane_sr(sample_rate)); + let f = if freq.is_finite() { f64::from(freq).clamp(1.0, sr * 0.49) } else { 1000.0 }; + let q = if q.is_finite() { f64::from(q).clamp(0.025, 100.0) } else { std::f64::consts::FRAC_1_SQRT_2 }; + let g = if gain_db.is_finite() { f64::from(gain_db).clamp(-48.0, 48.0) } else { 0.0 }; + let w0 = 2.0 * PI * f / sr; + let (sw, cw) = w0.sin_cos(); + let alpha = sw / (2.0 * q); + let a = 10f64.powf(g / 40.0); + let (b0, b1, b2, a0, a1, a2) = match kind { + FilterType::LowPass => ((1.0 - cw) / 2.0, 1.0 - cw, (1.0 - cw) / 2.0, 1.0 + alpha, -2.0 * cw, 1.0 - alpha), + FilterType::HighPass => ((1.0 + cw) / 2.0, -(1.0 + cw), (1.0 + cw) / 2.0, 1.0 + alpha, -2.0 * cw, 1.0 - alpha), + FilterType::BandPass => (alpha, 0.0, -alpha, 1.0 + alpha, -2.0 * cw, 1.0 - alpha), + FilterType::Notch => (1.0, -2.0 * cw, 1.0, 1.0 + alpha, -2.0 * cw, 1.0 - alpha), + FilterType::AllPass => (1.0 - alpha, -2.0 * cw, 1.0 + alpha, 1.0 + alpha, -2.0 * cw, 1.0 - alpha), + FilterType::Peak => (1.0 + alpha * a, -2.0 * cw, 1.0 - alpha * a, 1.0 + alpha / a, -2.0 * cw, 1.0 - alpha / a), + FilterType::LowShelf => { + let s = 2.0 * a.sqrt() * alpha; + ( + a * ((a + 1.0) - (a - 1.0) * cw + s), + 2.0 * a * ((a - 1.0) - (a + 1.0) * cw), + a * ((a + 1.0) - (a - 1.0) * cw - s), + (a + 1.0) + (a - 1.0) * cw + s, + -2.0 * ((a - 1.0) + (a + 1.0) * cw), + (a + 1.0) + (a - 1.0) * cw - s, + ) + } + FilterType::HighShelf => { + let s = 2.0 * a.sqrt() * alpha; + ( + a * ((a + 1.0) + (a - 1.0) * cw + s), + -2.0 * a * ((a - 1.0) + (a + 1.0) * cw), + a * ((a + 1.0) + (a - 1.0) * cw - s), + (a + 1.0) - (a - 1.0) * cw + s, + 2.0 * ((a - 1.0) - (a + 1.0) * cw), + (a + 1.0) - (a - 1.0) * cw - s, + ) + } + FilterType::LowPass1 | FilterType::HighPass1 => { + // Bilinear transform of 1/(s+1) with prewarped cutoff. + let k = (w0 / 2.0).tan(); + let norm = 1.0 / (1.0 + k); + let a1 = (k - 1.0) * norm; + return if kind == FilterType::LowPass1 { + Coeffs { b0: (k * norm) as f32, b1: (k * norm) as f32, b2: 0.0, a1: a1 as f32, a2: 0.0 } + } else { + Coeffs { b0: norm as f32, b1: -norm as f32, b2: 0.0, a1: a1 as f32, a2: 0.0 } + }; + } + }; + let c = Coeffs { b0: (b0 / a0) as f32, b1: (b1 / a0) as f32, b2: (b2 / a0) as f32, a1: (a1 / a0) as f32, a2: (a2 / a0) as f32 }; + if [c.b0, c.b1, c.b2, c.a1, c.a2].iter().all(|v| v.is_finite()) { c } else { Coeffs::IDENTITY } + } + + /// Magnitude response in dB at `freq` Hz. + pub fn magnitude_db(&self, freq: f32, sample_rate: f32) -> f32 { + let sr = f64::from(sane_sr(sample_rate)); + let f = if freq.is_finite() { f64::from(freq).clamp(0.0, sr * 0.5) } else { 0.0 }; + let w = 2.0 * PI * f / sr; + let (s1, c1) = w.sin_cos(); + let (s2, c2) = (2.0 * w).sin_cos(); + let (b0, b1, b2, a1, a2) = (f64::from(self.b0), f64::from(self.b1), f64::from(self.b2), f64::from(self.a1), f64::from(self.a2)); + let nr = b0 + b1 * c1 + b2 * c2; + let ni = -(b1 * s1 + b2 * s2); + let dr = 1.0 + a1 * c1 + a2 * c2; + let di = -(a1 * s1 + a2 * s2); + let num = nr * nr + ni * ni; + let den = (dr * dr + di * di).max(1e-30); + let db = 10.0 * (num / den).max(1e-30).log10(); + db.max(-300.0) as f32 + } +} + +/// A biquad section with its own state (transposed direct form II). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Biquad { + pub coeffs: Coeffs, + z1: f32, + z2: f32, +} + +impl Biquad { + pub fn new(coeffs: Coeffs) -> Self { + Self { coeffs, z1: 0.0, z2: 0.0 } + } + + /// Designs a new filter (see [`Coeffs::design`]). + pub fn design(kind: FilterType, freq: f32, q: f32, gain_db: f32, sample_rate: f32) -> Self { + Self::new(Coeffs::design(kind, freq, q, gain_db, sample_rate)) + } + + /// Re-designs the coefficients, keeping the state (for smooth sweeps). + pub fn set(&mut self, kind: FilterType, freq: f32, q: f32, gain_db: f32, sample_rate: f32) { + self.coeffs = Coeffs::design(kind, freq, q, gain_db, sample_rate); + } + + pub fn reset(&mut self) { + self.z1 = 0.0; + self.z2 = 0.0; + } + + #[inline] + pub fn process_sample(&mut self, x: f32) -> f32 { + let c = &self.coeffs; + let y = c.b0 * x + self.z1; + self.z1 = flush(c.b1 * x - c.a1 * y + self.z2); + self.z2 = flush(c.b2 * x - c.a2 * y); + if y.is_finite() { + y + } else { + self.reset(); + 0.0 + } + } + + pub fn process_block(&mut self, buf: &mut [f32]) { + for s in buf { + *s = self.process_sample(*s); + } + } + + /// Magnitude response in dB at `freq` Hz, for drawing curves. + pub fn magnitude_db(&self, freq: f32, sample_rate: f32) -> f32 { + self.coeffs.magnitude_db(freq, sample_rate) + } +} + +/// Butterworth Q values for cascaded sections making an `order`-th order filter (2, 4). +pub(crate) fn butterworth_qs(order: usize) -> &'static [f32] { + match order { + 4 => &[0.541_196_1, 1.306_563], + _ => &[std::f32::consts::FRAC_1_SQRT_2], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn peak_gain_at_center() { + let c = Coeffs::design(FilterType::Peak, 1000.0, 1.0, 6.0, 48_000.0); + assert!((c.magnitude_db(1000.0, 48_000.0) - 6.0).abs() < 0.01); + assert!(c.magnitude_db(20.0, 48_000.0).abs() < 0.1); + } + + #[test] + fn lowpass_minus3_at_cutoff() { + let c = Coeffs::design(FilterType::LowPass, 1000.0, std::f32::consts::FRAC_1_SQRT_2, 0.0, 48_000.0); + assert!((c.magnitude_db(1000.0, 48_000.0) + 3.01).abs() < 0.05); + let c1 = Coeffs::design(FilterType::HighPass1, 1000.0, 0.0, 0.0, 48_000.0); + assert!((c1.magnitude_db(1000.0, 48_000.0) + 3.01).abs() < 0.05); + } + + #[test] + fn hostile_inputs() { + let c = Coeffs::design(FilterType::Peak, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0); + assert!(c.b0.is_finite()); + let mut b = Biquad::new(c); + assert!(b.process_sample(f32::NAN) == 0.0); + } +} diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs new file mode 100644 index 00000000..83c7b662 --- /dev/null +++ b/crates/dsp/src/lib.rs @@ -0,0 +1,237 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! SoundCraft DSP. +//! +//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and two +//! instruments), metering (peak, RMS, ITU-R BS.1770 loudness), spectrum analysis and offline +//! AudioSuite-style whole-buffer processing. +//! +//! Every algorithm here is an original implementation of textbook DSP (RBJ cookbook biquads, +//! feed-forward log-domain dynamics, feedback-delay-network reverbs, WSOLA, windowed-sinc +//! resampling). Plugins never panic on any parameter value: NaN falls back to the default and +//! infinities clamp to the range. `Plugin::process` never allocates; buffers are sized in +//! `Plugin::prepare`. +//! +//! Audio is planar `f32`: `io[channel][frame]`. + +pub mod biquad; +pub mod meter; +pub mod offline; +mod osc; +pub mod pan; +mod params; +pub mod plugins; +pub mod spectrum; +mod util; + +pub use offline::FadeShape; +pub use osc::Waveform; +pub use plugins::eq::{eq1_response, eq7_response}; + +/// Plugin category, used to group the insert menu. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum Category { + Eq, + Dynamics, + PitchShift, + Reverb, + Delay, + Modulation, + Harmonic, + Dither, + Instrument, + Other, +} + +/// Display unit of a parameter. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] +pub enum Unit { + None, + Db, + Hz, + Ms, + Percent, + Ratio, + Semitones, + Cents, + Seconds, + Toggle, + Choice, +} + +/// How a control maps its 0..1 travel onto the parameter range. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] +pub enum Taper { + Linear, + Log, +} + +/// Static description of one plugin parameter. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct ParamInfo { + pub id: &'static str, + pub name: &'static str, + pub min: f32, + pub max: f32, + pub default: f32, + pub unit: Unit, + pub taper: Taper, + /// Labels for `Unit::Choice` parameters (value is the index). + pub choices: &'static [&'static str], +} + +impl ParamInfo { + /// Clamps any value (including NaN/inf) into this parameter's legal range. + /// NaN becomes the default; toggles and choices round to whole numbers. + pub fn clamp(&self, value: f32) -> f32 { + let v = if value.is_nan() { self.default } else { value }; + let v = v.clamp(self.min, self.max); + match self.unit { + Unit::Toggle | Unit::Choice => v.round().clamp(self.min, self.max), + _ => v, + } + } + + /// Maps a value to 0..1 control travel honoring the taper. + pub fn to_normalized(&self, value: f32) -> f32 { + let v = self.clamp(value); + let span = self.max - self.min; + if span <= 0.0 { + return 0.0; + } + match self.taper { + Taper::Log if self.min > 0.0 => ((v / self.min).ln() / (self.max / self.min).ln()).clamp(0.0, 1.0), + _ => ((v - self.min) / span).clamp(0.0, 1.0), + } + } + + /// Maps 0..1 control travel back to a value honoring the taper. + pub fn from_normalized(&self, t: f32) -> f32 { + let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) }; + let v = match self.taper { + Taper::Log if self.min > 0.0 => self.min * (self.max / self.min).powf(t), + _ => self.min + (self.max - self.min) * t, + }; + self.clamp(v) + } + + /// Human-readable label for a value (choice label, On/Off, or number with unit). + pub fn format(&self, value: f32) -> String { + let v = self.clamp(value); + match self.unit { + Unit::Choice => self.choices.get(v as usize).map(|s| (*s).to_string()).unwrap_or_else(|| format!("{v}")), + Unit::Toggle => if v >= 0.5 { "On" } else { "Off" }.to_string(), + Unit::Db => { + if v <= -95.9 { + "-inf dB".to_string() + } else { + format!("{v:.1} dB") + } + } + Unit::Hz => { + if v >= 1000.0 { + format!("{:.2} kHz", v / 1000.0) + } else { + format!("{v:.1} Hz") + } + } + Unit::Ms => format!("{v:.1} ms"), + Unit::Percent => format!("{v:.0}%"), + Unit::Ratio => format!("{v:.1}:1"), + Unit::Semitones => format!("{v:.0} st"), + Unit::Cents => format!("{v:.0} ct"), + Unit::Seconds => format!("{v:.2} s"), + Unit::None => format!("{v:.2}"), + } + } +} + +/// Static description of a plugin. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct PluginInfo { + pub id: &'static str, + pub name: &'static str, + /// At most 8 characters, for insert slots. + pub short_name: &'static str, + pub category: Category, + pub params: &'static [ParamInfo], + pub is_instrument: bool, + /// Usable as an offline (AudioSuite-style) process. + pub audiosuite: bool, +} + +impl PluginInfo { + /// Looks up a parameter description by id. + pub fn param(&self, id: &str) -> Option<&'static ParamInfo> { + self.params.iter().find(|p| p.id == id) + } +} + +/// A realtime audio processor or instrument. +/// +/// Contract: `process` never allocates or panics. It processes `min(frames, io[c].len())` frames +/// of at most the channel count given to `prepare`; extra channels are left untouched. +/// Instruments replace the buffer contents with their output. +pub trait Plugin: Send { + fn info(&self) -> &'static PluginInfo; + fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize); + fn reset(&mut self); + /// Sets a parameter (clamped into range; NaN becomes the default). `false` if `id` is unknown. + fn set_param(&mut self, id: &str, value: f32) -> bool; + fn param(&self, id: &str) -> Option; + /// In-place processing of `channels` planar buffers each `frames` long. + fn process(&mut self, io: &mut [Vec], frames: usize); + /// Processing latency in samples (for delay compensation). + fn latency(&self) -> usize { + 0 + } + /// How long the output keeps ringing after the input stops, in samples. + fn tail_samples(&self) -> usize { + 0 + } + /// Gain reduction in dB for metering (a non-negative amount); 0 for non-dynamics plugins. + fn gain_reduction_db(&self) -> f32 { + 0.0 + } + /// Instruments: note events at sample offsets within the next block. + fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {} + fn note_off(&mut self, _offset: usize, _note: u8) {} + fn all_notes_off(&mut self) {} +} + +/// The plugin registry. +pub fn plugins() -> &'static [&'static PluginInfo] { + plugins::REGISTRY +} + +/// Creates a plugin by id, prepared for 48 kHz stereo with 1024-frame blocks. +/// Hosts should call `prepare` again with their real settings. +pub fn create(id: &str) -> Option> { + let mut p = plugins::instantiate(id)?; + p.prepare(48_000.0, 1024, 2); + Some(p) +} + +/// Looks up a plugin description by id. +pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> { + plugins::REGISTRY.iter().copied().find(|i| i.id == id) +} + +/// Silence floor used by the dB helpers. +pub const MIN_DB: f32 = -144.0; + +/// Converts decibels to linear gain. Anything at or below -144 dB (or NaN) is silence. +pub fn db_to_gain(db: f32) -> f32 { + if db.is_nan() || db <= MIN_DB { 0.0 } else { 10f32.powf(db.min(200.0) / 20.0) } +} + +/// Converts linear gain (sign ignored) to decibels, floored at -144 dB. +pub fn gain_to_db(g: f32) -> f32 { + let a = g.abs(); + if a.is_nan() { + return MIN_DB; + } + if a.is_infinite() { + return f32::MAX; + } + (20.0 * a.log10()).max(MIN_DB) +} diff --git a/crates/dsp/src/meter.rs b/crates/dsp/src/meter.rs new file mode 100644 index 00000000..bff76e72 --- /dev/null +++ b/crates/dsp/src/meter.rs @@ -0,0 +1,381 @@ +//! Level meters: DAW-style peak meter, RMS meter, and an ITU-R BS.1770 loudness meter +//! (momentary, short-term and gated integrated LUFS plus 4× oversampled true peak). +//! +//! None of the `process` methods allocate. + +use crate::biquad::{Biquad, Coeffs}; +use crate::util::sane_sr; +use crate::{MIN_DB, gain_to_db}; + +/// Peak meter with instant attack, linear-in-dB release, peak hold and a clip latch. +#[derive(Debug, Clone)] +pub struct PeakMeter { + sr: f32, + level_db: f32, + hold_db: f32, + hold_left: f32, + hold_secs: f32, + release_db_per_sec: f32, + clipped: bool, +} + +impl PeakMeter { + /// A meter with 20 dB/s release and a 2 s peak hold. + pub fn new(sample_rate: f32) -> Self { + Self { sr: sane_sr(sample_rate), level_db: MIN_DB, hold_db: MIN_DB, hold_left: 0.0, hold_secs: 2.0, release_db_per_sec: 20.0, clipped: false } + } + + pub fn set_release_db_per_sec(&mut self, rate: f32) { + if rate.is_finite() { + self.release_db_per_sec = rate.clamp(0.1, 1000.0); + } + } + + pub fn set_hold_secs(&mut self, secs: f32) { + if secs.is_finite() { + self.hold_secs = secs.clamp(0.0, 60.0); + } + } + + /// Feeds one block of samples. + pub fn process(&mut self, samples: &[f32]) { + let peak = samples.iter().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs())); + self.feed(peak, samples.len()); + } + + /// Feeds an already-computed block peak (linear) covering `frames` samples. + pub fn feed(&mut self, peak: f32, frames: usize) { + let dt = frames as f32 / self.sr; + if peak >= 1.0 { + self.clipped = true; + } + let peak_db = gain_to_db(peak); + let decayed = (self.level_db - self.release_db_per_sec * dt).max(MIN_DB); + self.level_db = decayed.max(peak_db); + if peak_db >= self.hold_db { + self.hold_db = peak_db; + self.hold_left = self.hold_secs; + } else if self.hold_left > 0.0 { + self.hold_left -= dt; + } else { + self.hold_db = (self.hold_db - self.release_db_per_sec * dt).max(self.level_db); + } + } + + /// Displayed level in dBFS. + pub fn level_db(&self) -> f32 { + self.level_db + } + + /// Held peak in dBFS. + pub fn peak_hold_db(&self) -> f32 { + self.hold_db + } + + /// True once any sample reached full scale, until [`PeakMeter::reset_clip`]. + pub fn clipped(&self) -> bool { + self.clipped + } + + pub fn reset_clip(&mut self) { + self.clipped = false; + } + + pub fn reset(&mut self) { + self.level_db = MIN_DB; + self.hold_db = MIN_DB; + self.hold_left = 0.0; + self.clipped = false; + } +} + +/// Exponentially averaged RMS meter (300 ms time constant by default). +#[derive(Debug, Clone)] +pub struct RmsMeter { + sr: f32, + coef: f32, + ms: f64, +} + +impl RmsMeter { + pub fn new(sample_rate: f32) -> Self { + let mut m = Self { sr: sane_sr(sample_rate), coef: 0.0, ms: 0.0 }; + m.set_window_ms(300.0); + m + } + + pub fn set_window_ms(&mut self, ms: f32) { + self.coef = crate::util::time_coef(if ms.is_finite() { ms.clamp(1.0, 10_000.0) } else { 300.0 }, self.sr); + } + + pub fn process(&mut self, samples: &[f32]) { + let k = f64::from(1.0 - self.coef); + for &x in samples { + let x = if x.is_finite() { f64::from(x) } else { 0.0 }; + self.ms += (x * x - self.ms) * k; + } + if self.ms < 1e-30 { + self.ms = 0.0; + } + } + + /// Current RMS level in dBFS (sine-referenced: an RMS of 0.707 reads -3 dB). + pub fn rms_db(&self) -> f32 { + gain_to_db(self.ms.sqrt() as f32) + } + + pub fn reset(&mut self) { + self.ms = 0.0; + } +} + +const SUB_BLOCKS: usize = 30; // 3 s of 100 ms sub-blocks +const HIST_MIN: f32 = -70.0; +const HIST_STEP: f32 = 0.05; +const HIST_BINS: usize = 1800; // -70 .. +20 LUFS +const TP_PHASES: usize = 4; +const TP_TAPS: usize = 12; + +/// ITU-R BS.1770 / EBU R128 loudness meter. +#[derive(Debug, Clone)] +pub struct LoudnessMeter { + sr: f32, + weights: Vec, + filters: Vec<[Biquad; 2]>, + block_len: usize, + block_pos: usize, + block_acc: Vec, + sub: [f64; SUB_BLOCKS], + sub_idx: usize, + sub_filled: usize, + hist_count: Vec, + hist_energy: Vec, + tp_coefs: [[f32; TP_TAPS]; TP_PHASES], + tp_hist: Vec<[f32; TP_TAPS]>, + true_peak: f32, + max_momentary: f32, +} + +/// Loudness from mean-square energy (BS.1770 eq. 2). +fn lufs(energy: f64) -> f32 { + if energy <= 1e-20 { MIN_DB } else { (-0.691 + 10.0 * energy.log10()) as f32 } +} + +/// K-weighting pre-filter (high shelf) and RLB high-pass designed for any sample rate. +fn k_weighting(sr: f32) -> [Coeffs; 2] { + let sr = f64::from(sr); + let pi = std::f64::consts::PI; + // Stage 1: shelving. + let (f0, g, q) = (1_681.974_450_955_533, 3.999_843_853_973_347, 0.707_175_236_955_419_6); + let k = (pi * f0 / sr).tan(); + let vh = 10f64.powf(g / 20.0); + let vb = vh.powf(0.499_666_774_154_541_6); + let a0 = 1.0 + k / q + k * k; + let s1 = Coeffs { + b0: ((vh + vb * k / q + k * k) / a0) as f32, + b1: (2.0 * (k * k - vh) / a0) as f32, + b2: ((vh - vb * k / q + k * k) / a0) as f32, + a1: (2.0 * (k * k - 1.0) / a0) as f32, + a2: ((1.0 - k / q + k * k) / a0) as f32, + }; + // Stage 2: high-pass. + let (f0, q) = (38.135_470_876_024_44, 0.500_327_037_323_877_3); + let k = (pi * f0 / sr).tan(); + let a0 = 1.0 + k / q + k * k; + let s2 = Coeffs { b0: 1.0, b1: -2.0, b2: 1.0, a1: (2.0 * (k * k - 1.0) / a0) as f32, a2: ((1.0 - k / q + k * k) / a0) as f32 }; + [s1, s2] +} + +impl LoudnessMeter { + /// Creates a meter. Channel weights default to 1.0, except a 6-channel (5.1: L R C LFE Ls Rs) + /// layout which uses 0 for LFE and 1.41 for the surrounds. + pub fn new(sample_rate: f32, channels: usize) -> Self { + let sr = sane_sr(sample_rate); + let channels = channels.clamp(1, crate::util::MAX_CHANNELS); + let weights = if channels == 6 { vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41] } else { vec![1.0; channels] }; + let [c1, c2] = k_weighting(sr); + // Interpolation filter: windowed sinc at the original Nyquist, split into 4 phases. + let mut tp_coefs = [[0.0f32; TP_TAPS]; TP_PHASES]; + let total = TP_TAPS * TP_PHASES; + let center = (total as f64 - 1.0) / 2.0; + for i in 0..total { + let t = (i as f64 - center) / TP_PHASES as f64; + let sinc = if t.abs() < 1e-12 { 1.0 } else { (std::f64::consts::PI * t).sin() / (std::f64::consts::PI * t) }; + let x = i as f64 / (total as f64 - 1.0); + let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * x).cos() + 0.08 * (4.0 * std::f64::consts::PI * x).cos(); + if let Some(slot) = tp_coefs.get_mut(i % TP_PHASES).and_then(|ph| ph.get_mut(i / TP_PHASES)) { + *slot = (sinc * w) as f32; + } + } + for ph in &mut tp_coefs { + let s: f32 = ph.iter().sum(); + if s.abs() > 1e-6 { + ph.iter_mut().for_each(|c| *c /= s); + } + } + Self { + sr, + weights, + filters: vec![[Biquad::new(c1), Biquad::new(c2)]; channels], + block_len: ((sr * 0.1).round() as usize).max(1), + block_pos: 0, + block_acc: vec![0.0; channels], + sub: [0.0; SUB_BLOCKS], + sub_idx: 0, + sub_filled: 0, + hist_count: vec![0; HIST_BINS], + hist_energy: vec![0.0; HIST_BINS], + tp_coefs, + tp_hist: vec![[0.0; TP_TAPS]; channels], + true_peak: 0.0, + max_momentary: MIN_DB, + } + } + + /// Overrides a channel's weighting (e.g. 1.41 for surrounds, 0 to exclude LFE). + pub fn set_channel_weight(&mut self, channel: usize, weight: f32) { + if let Some(w) = self.weights.get_mut(channel) { + *w = if weight.is_finite() { weight.clamp(0.0, 4.0) } else { 1.0 }; + } + } + + pub fn sample_rate(&self) -> f32 { + self.sr + } + + pub fn reset(&mut self) { + for f in &mut self.filters { + f[0].reset(); + f[1].reset(); + } + self.block_pos = 0; + self.block_acc.iter_mut().for_each(|v| *v = 0.0); + self.sub = [0.0; SUB_BLOCKS]; + self.sub_idx = 0; + self.sub_filled = 0; + self.hist_count.iter_mut().for_each(|v| *v = 0); + self.hist_energy.iter_mut().for_each(|v| *v = 0.0); + self.tp_hist.iter_mut().for_each(|h| *h = [0.0; TP_TAPS]); + self.true_peak = 0.0; + self.max_momentary = MIN_DB; + } + + /// Feeds planar audio. Channels beyond the meter's channel count are ignored. + pub fn process(&mut self, io: &[Vec], frames: usize) { + let ch = self.filters.len().min(io.len()); + let frames = io.iter().take(ch).map(Vec::len).fold(frames, usize::min); + for n in 0..frames { + for c in 0..ch { + let x = io.get(c).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0); + if let Some([f1, f2]) = self.filters.get_mut(c) { + let y = f2.process_sample(f1.process_sample(x)); + if let Some(acc) = self.block_acc.get_mut(c) { + *acc += f64::from(y) * f64::from(y); + } + } + self.true_peak_sample(c, x); + } + self.block_pos += 1; + if self.block_pos >= self.block_len { + self.finish_sub_block(); + } + } + } + + fn true_peak_sample(&mut self, c: usize, x: f32) { + let Some(h) = self.tp_hist.get_mut(c) else { return }; + h.copy_within(0..TP_TAPS - 1, 1); + h[0] = x; + let mut peak = x.abs(); + for ph in &self.tp_coefs { + let y: f32 = ph.iter().zip(h.iter()).map(|(a, b)| a * b).sum(); + peak = peak.max(y.abs()); + } + if peak > self.true_peak { + self.true_peak = peak; + } + } + + fn finish_sub_block(&mut self) { + let len = self.block_pos.max(1) as f64; + let mut energy = 0.0; + for (acc, w) in self.block_acc.iter_mut().zip(&self.weights) { + energy += f64::from(*w) * *acc / len; + *acc = 0.0; + } + self.block_pos = 0; + if let Some(s) = self.sub.get_mut(self.sub_idx) { + *s = energy; + } + self.sub_idx = (self.sub_idx + 1) % SUB_BLOCKS; + self.sub_filled = (self.sub_filled + 1).min(SUB_BLOCKS); + if self.sub_filled >= 4 { + let z = self.recent_energy(4); + let l = lufs(z); + self.max_momentary = self.max_momentary.max(l); + if l > HIST_MIN { + let bin = (((l - HIST_MIN) / HIST_STEP) as usize).min(HIST_BINS - 1); + if let (Some(c), Some(e)) = (self.hist_count.get_mut(bin), self.hist_energy.get_mut(bin)) { + *c += 1; + *e += z; + } + } + } + } + + /// Mean energy of the last `n` completed 100 ms sub-blocks. + fn recent_energy(&self, n: usize) -> f64 { + let n = n.min(self.sub_filled); + if n == 0 { + return 0.0; + } + let mut sum = 0.0; + for k in 1..=n { + let idx = (self.sub_idx + SUB_BLOCKS - k) % SUB_BLOCKS; + sum += self.sub.get(idx).copied().unwrap_or(0.0); + } + sum / n as f64 + } + + /// Momentary loudness (400 ms window), LUFS. -144 until 400 ms have been measured. + pub fn momentary_lufs(&self) -> f32 { + if self.sub_filled < 4 { MIN_DB } else { lufs(self.recent_energy(4)) } + } + + /// Short-term loudness (3 s window, or what is available so far), LUFS. + pub fn short_term_lufs(&self) -> f32 { + if self.sub_filled < 4 { MIN_DB } else { lufs(self.recent_energy(SUB_BLOCKS)) } + } + + /// Highest momentary loudness seen. + pub fn max_momentary_lufs(&self) -> f32 { + self.max_momentary + } + + /// Gated integrated loudness (absolute gate -70 LUFS, relative gate -10 LU), LUFS. + pub fn integrated_lufs(&self) -> f32 { + let (mut n, mut e) = (0u64, 0.0f64); + for (c, s) in self.hist_count.iter().zip(&self.hist_energy) { + n += c; + e += s; + } + if n == 0 { + return MIN_DB; + } + let gate = lufs(e / n as f64) - 10.0; + let (mut n2, mut e2) = (0u64, 0.0f64); + for (i, (c, s)) in self.hist_count.iter().zip(&self.hist_energy).enumerate() { + let center = HIST_MIN + (i as f32 + 0.5) * HIST_STEP; + if center > gate { + n2 += c; + e2 += s; + } + } + if n2 == 0 { MIN_DB } else { lufs(e2 / n2 as f64) } + } + + /// Maximum true peak (4× oversampled) in dBTP. + pub fn true_peak_db(&self) -> f32 { + gain_to_db(self.true_peak) + } +} diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs new file mode 100644 index 00000000..b9a5eac4 --- /dev/null +++ b/crates/dsp/src/offline.rs @@ -0,0 +1,563 @@ +//! AudioSuite-style whole-buffer processing on planar `f32` audio. +//! +//! These functions may allocate (they are not for the audio thread) but never panic: lengths, +//! rates and ratios are sanitized and output sizes are capped at [`MAX_OFFLINE_SAMPLES`] per +//! channel. + +use std::f64::consts::PI; + +use rustfft::{FftPlanner, num_complex::Complex}; + +use crate::osc::Osc; +pub use crate::osc::Waveform; +use crate::util::sane_sr; +use crate::{Plugin, db_to_gain, gain_to_db}; + +/// Largest number of samples per channel any offline function will produce. +pub const MAX_OFFLINE_SAMPLES: usize = 1 << 28; + +/// Fade curves. [`FadeShape::gain`] is the fade-in curve; a fade-out uses `gain(1 - t)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum FadeShape { + Linear, + /// Constant power (sin/cos), for crossfades between uncorrelated material. + #[default] + EqualPower, + /// Raised cosine. + SCurve, + /// Slow start, fast finish. + Exponential, + /// Fast start, slow finish. + Logarithmic, +} + +impl FadeShape { + pub const ALL: [FadeShape; 5] = [FadeShape::Linear, FadeShape::EqualPower, FadeShape::SCurve, FadeShape::Exponential, FadeShape::Logarithmic]; + + /// Fade-in gain at position `t` in 0..1 (0 → silent, 1 → unity). NaN is treated as 0. + pub fn gain(self, t: f32) -> f32 { + let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) }; + const K: f32 = 4.0; + match self { + FadeShape::Linear => t, + FadeShape::EqualPower => (t * std::f32::consts::FRAC_PI_2).sin(), + FadeShape::SCurve => 0.5 - 0.5 * (t * std::f32::consts::PI).cos(), + FadeShape::Exponential => ((K * t).exp() - 1.0) / (K.exp() - 1.0), + FadeShape::Logarithmic => (1.0 + (K.exp() - 1.0) * t).ln() / K, + } + } +} + +fn max_len(ch: &[Vec]) -> usize { + ch.iter().map(Vec::len).max().unwrap_or(0) +} + +fn peak(ch: &[Vec]) -> f32 { + ch.iter().flatten().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs())) +} + +/// Peak-normalizes to `target_db` dBFS, either all channels together or each on its own. +/// Silent channels are left alone. +pub fn normalize(ch: &mut [Vec], target_db: f32, per_channel: bool) { + let target = db_to_gain(if target_db.is_nan() { 0.0 } else { target_db.clamp(-144.0, 24.0) }); + if per_channel { + for c in ch.iter_mut() { + let p = peak(std::slice::from_ref(c)); + if p > 0.0 { + let g = target / p; + c.iter_mut().for_each(|s| *s *= g); + } + } + } else { + let p = peak(ch); + if p > 0.0 { + let g = target / p; + ch.iter_mut().flatten().for_each(|s| *s *= g); + } + } +} + +/// Applies a gain change in dB. +pub fn gain(ch: &mut [Vec], db: f32) { + let g = db_to_gain(if db.is_nan() { 0.0 } else { db.min(96.0) }); + ch.iter_mut().flatten().for_each(|s| *s *= g); +} + +/// Flips polarity. +pub fn invert(ch: &mut [Vec]) { + ch.iter_mut().flatten().for_each(|s| *s = -*s); +} + +/// Reverses each channel in time. +pub fn reverse(ch: &mut [Vec]) { + ch.iter_mut().for_each(|c| c.reverse()); +} + +/// Removes each channel's DC offset (subtracts its mean). +pub fn remove_dc(ch: &mut [Vec]) { + for c in ch.iter_mut() { + 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; + c.iter_mut().for_each(|s| *s -= m); + } +} + +/// Fades in over the first `fade_in` samples and out over the last `fade_out` samples. +pub fn fade(ch: &mut [Vec], fade_in: usize, fade_out: usize, shape: FadeShape) { + for c in ch.iter_mut() { + let len = c.len(); + let fi = fade_in.min(len); + for (i, s) in c.iter_mut().take(fi).enumerate() { + *s *= shape.gain(i as f32 / fi as f32); + } + let fo = fade_out.min(len); + for (j, s) in c.iter_mut().skip(len - fo).enumerate() { + *s *= shape.gain((fo - 1 - j) as f32 / fo as f32); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Resampling (Kaiser-windowed sinc) + +fn bessel_i0(x: f64) -> f64 { + let mut sum = 1.0; + let mut term = 1.0; + let y = x * x / 4.0; + for k in 1..64 { + term *= y / (k as f64 * k as f64); + sum += term; + if term < sum * 1e-12 { + break; + } + } + sum +} + +const SINC_ZC: f64 = 32.0; +const SINC_RES: usize = 512; + +/// Precomputed one-sided windowed-sinc kernel for a given cutoff (fraction of input Nyquist). +struct SincTable { + table: Vec, + half_width: f64, +} + +impl SincTable { + fn new(cutoff: f64) -> Self { + let cutoff = cutoff.clamp(0.01, 1.0); + let half_width = SINC_ZC / cutoff; + let n = (half_width * SINC_RES as f64).ceil() as usize + 2; + let beta = 9.0; + let i0b = bessel_i0(beta); + let table = (0..n) + .map(|i| { + let t = i as f64 / SINC_RES as f64; + let x = t / half_width; + if x >= 1.0 { + return 0.0; + } + let arg = PI * cutoff * t; + let sinc = if arg.abs() < 1e-12 { 1.0 } else { arg.sin() / arg }; + let w = bessel_i0(beta * (1.0 - x * x).max(0.0).sqrt()) / i0b; + (cutoff * sinc * w) as f32 + }) + .collect(); + Self { table, half_width } + } + + #[inline] + fn at(&self, t: f64) -> f32 { + let pos = t.abs() * SINC_RES as f64; + let i = pos as usize; + let f = (pos - i as f64) as f32; + match (self.table.get(i), self.table.get(i + 1)) { + (Some(a), Some(b)) => a + (b - a) * f, + (Some(a), None) => *a, + _ => 0.0, + } + } +} + +/// Resamples one channel to exactly `out_len` samples, where output sample `n` is read at input +/// time `n / ratio`. +fn resample_channel(x: &[f32], ratio: f64, out_len: usize, table: &SincTable) -> Vec { + let len = x.len() as i64; + let hw = table.half_width; + (0..out_len) + .map(|n| { + let t = n as f64 / ratio; + let lo = (t - hw).ceil().max(0.0) as i64; + let hi = ((t + hw).floor() as i64).min(len - 1); + let mut acc = 0.0f32; + let mut j = lo; + while j <= hi { + if let Some(&v) = x.get(j as usize) { + acc += v * table.at(t - j as f64); + } + j += 1; + } + acc + }) + .collect() +} + +fn resample_ratio(ch: &[Vec], ratio: f64, out_len: usize) -> Vec> { + if !ratio.is_finite() || ratio <= 0.0 { + return ch.iter().map(|_| vec![0.0; out_len]).collect(); + } + // Anti-alias below the lower of the two Nyquists, with a little transition room. + let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 }; + let table = SincTable::new(cutoff); + ch.iter().map(|c| resample_channel(c, ratio, out_len, &table)).collect() +} + +/// High-quality windowed-sinc sample-rate conversion from `from` Hz to `to` Hz. +pub fn resample(ch: &[Vec], from: u32, to: u32) -> Vec> { + if from == 0 || to == 0 || from == to { + return ch.to_vec(); + } + let ratio = f64::from(to) / f64::from(from); + ch.iter() + .map(|c| { + let out_len = ((c.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES); + resample_ratio(std::slice::from_ref(c), ratio, out_len).into_iter().next().unwrap_or_default() + }) + .collect() +} + +// --------------------------------------------------------------------------------------------- +// Time stretch (WSOLA) and pitch shift + +/// Pitch-preserving time stretch by WSOLA (waveform-similarity overlap-add). +/// `ratio` = output length / input length, clamped to 0.1 ..= 10. All channels share the same +/// segment choices so the stereo image stays coherent. +pub fn time_stretch(ch: &[Vec], ratio: f64, sample_rate: f32) -> Vec> { + let len = max_len(ch); + let ratio = if ratio.is_finite() { ratio.clamp(0.1, 10.0) } else { 1.0 }; + let out_len = ((len as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES); + if len == 0 || ch.is_empty() { + return ch.iter().map(|_| vec![0.0; out_len]).collect(); + } + if (ratio - 1.0).abs() < 1e-9 { + return ch.iter().map(|c| c.iter().copied().chain(std::iter::repeat(0.0)).take(out_len).collect()).collect(); + } + let sr = f64::from(sane_sr(sample_rate)); + let frame = (((sr * 0.04) as usize) & !1).clamp(64, 8192); + let hop = frame / 2; + let tol = ((sr * 0.010) as usize).clamp(8, 2048) as i64; + let window: Vec = (0..frame).map(|i| (0.5 - 0.5 * (2.0 * PI * i as f64 / frame as f64).cos()) as f32).collect(); + let nch = ch.len() as f32; + let guide: Vec = (0..len).map(|i| ch.iter().map(|c| c.get(i).copied().unwrap_or(0.0)).sum::() / nch).collect(); + let g = |i: i64| -> f32 { if i < 0 { 0.0 } else { guide.get(i as usize).copied().unwrap_or(0.0) } }; + + let buf_len = out_len + frame; + let mut out: Vec> = ch.iter().map(|_| vec![0.0; buf_len]).collect(); + let mut wsum = vec![0.0f32; buf_len]; + let mut prev_in: i64 = 0; + let mut k = 0usize; + loop { + let out_pos = k * hop; + if out_pos >= out_len { + break; + } + let in_pos = if k == 0 { + 0 + } else { + let nominal = (out_pos as f64 / ratio).round() as i64; + let natural = prev_in + hop as i64; + // Similarity of candidate start `p` with the natural continuation (stride 2). + let score = |p: i64| -> f32 { + let (mut xy, mut yy) = (0.0f32, 1e-9f32); + let mut i = 0i64; + while i < hop as i64 { + let a = g(natural + i); + let b = g(p + i); + xy += a * b; + yy += b * b; + i += 2; + } + xy / yy.sqrt() + }; + let lo = (nominal - tol).max(0); + let hi = nominal + tol; + let mut best = nominal.max(0); + let mut best_s = f32::NEG_INFINITY; + let mut p = lo; + while p <= hi { + let s = score(p); + if s > best_s { + best_s = s; + best = p; + } + p += 4; + } + let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi)); + for p in rlo..=rhi { + let s = score(p); + if s > best_s { + best_s = s; + best = p; + } + } + best + }; + for (c, o) in ch.iter().zip(out.iter_mut()) { + for (i, w) in window.iter().enumerate() { + let src = in_pos as usize + i; + let v = c.get(src).copied().unwrap_or(0.0); + if let Some(d) = o.get_mut(out_pos + i) { + *d += v * w; + } + } + } + for (i, w) in window.iter().enumerate() { + if let Some(d) = wsum.get_mut(out_pos + i) { + *d += w; + } + } + prev_in = in_pos; + k += 1; + } + for o in out.iter_mut() { + for (s, w) in o.iter_mut().zip(&wsum) { + if *w > 1e-3 { + *s /= w; + } + } + o.truncate(out_len); + } + out +} + +/// Length-preserving pitch shift by `semitones` (clamped to ±48): WSOLA stretch followed by +/// windowed-sinc resampling back to the original length. +pub fn pitch_shift(ch: &[Vec], semitones: f32, sample_rate: f32) -> Vec> { + let st = if semitones.is_finite() { semitones.clamp(-48.0, 48.0) } else { 0.0 }; + if st.abs() < 1e-4 { + return ch.to_vec(); + } + let factor = 2f64.powf(f64::from(st) / 12.0); + let stretched = time_stretch(ch, factor, sample_rate); + let len = max_len(ch); + let slen = max_len(&stretched); + if slen == 0 || len == 0 { + return ch.iter().map(|c| vec![0.0; c.len()]).collect(); + } + let ratio = len as f64 / slen as f64; + let mut out = resample_ratio(&stretched, ratio, len); + for (o, c) in out.iter_mut().zip(ch) { + o.truncate(c.len()); + } + out +} + +// --------------------------------------------------------------------------------------------- +// Generators and analysis + +/// Generates a test signal. Periodic waveforms peak at `level_db`; noise is scaled so its peak +/// stays at or below `level_db`. +pub fn signal_generator(kind: Waveform, freq: f32, level_db: f32, sample_rate: f32, frames: usize, channels: usize) -> Vec> { + let sr = sane_sr(sample_rate); + let frames = frames.min(MAX_OFFLINE_SAMPLES); + let channels = channels.min(crate::util::MAX_CHANNELS); + let f = if freq.is_finite() { freq.clamp(0.0, sr * 0.49) } else { 1000.0 }; + let g = db_to_gain(if level_db.is_nan() { -20.0 } else { level_db.min(0.0) }); + let mut osc = Osc::new(0x1234_5678); + let mono: Vec = (0..frames).map(|_| osc.next(kind, f / sr) * g).collect(); + (0..channels).map(|_| mono.clone()).collect() +} + +/// Onset detection by half-wave-rectified spectral flux with an adaptive threshold. +/// `sensitivity` 0..1 (higher finds more transients). Returns sample positions, ascending. +pub fn detect_transients(ch: &[Vec], sample_rate: f32, sensitivity: f32) -> Vec { + let len = max_len(ch); + let sr = sane_sr(sample_rate); + let sens = if sensitivity.is_finite() { sensitivity.clamp(0.0, 1.0) } else { 0.5 }; + let n = ((sr * 0.023) as usize).clamp(64, 8192).next_power_of_two(); + let hop = n / 4; + if len < n || ch.is_empty() { + return Vec::new(); + } + let nch = ch.len() as f32; + let mono: Vec = (0..len).map(|i| ch.iter().map(|c| c.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).sum::() / nch).collect(); + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(n); + let window: Vec = (0..n).map(|i| (0.5 - 0.5 * (2.0 * PI * i as f64 / n as f64).cos()) as f32).collect(); + let frames = (len - n) / hop + 1; + let mut prev = vec![0.0f32; n / 2 + 1]; + let mut buf = vec![Complex::new(0.0f32, 0.0); n]; + let mut flux = Vec::with_capacity(frames); + for m in 0..frames { + let start = m * hop; + for (i, b) in buf.iter_mut().enumerate() { + *b = Complex::new(mono.get(start + i).copied().unwrap_or(0.0) * window.get(i).copied().unwrap_or(0.0), 0.0); + } + fft.process(&mut buf); + let mut f = 0.0; + for (b, p) in buf.iter().zip(prev.iter_mut()) { + let mag = (1.0 + 100.0 * b.norm()).ln(); + f += (mag - *p).max(0.0); + *p = mag; + } + flux.push(if m == 0 { 0.0 } else { f }); + } + let max_flux = flux.iter().copied().fold(0.0f32, f32::max); + if max_flux <= 1e-6 { + return Vec::new(); + } + let w = ((0.1 * sr) as usize / hop).max(2); + let mult = 1.0 + 2.5 * (1.0 - sens); + let delta = max_flux * (0.02 + 0.25 * (1.0 - sens)); + let min_gap = ((0.05 * sr) as usize / hop).max(1); + let mut out = Vec::new(); + let mut last: Option = None; + for m in 1..flux.len() { + let cur = flux.get(m).copied().unwrap_or(0.0); + let lo = m.saturating_sub(w); + let hi = (m + w + 1).min(flux.len()); + let local = flux.get(lo..hi).map(|s| s.iter().sum::() / s.len().max(1) as f32).unwrap_or(0.0); + let prev_v = flux.get(m - 1).copied().unwrap_or(0.0); + let next_v = flux.get(m + 1).copied().unwrap_or(0.0); + if cur > local * mult + delta && cur >= prev_v && cur > next_v && last.is_none_or(|l| m - l >= min_gap) { + last = Some(m); + // Refine: first sample in the analysis frame reaching a quarter of the frame's peak. + let start = m * hop; + let seg = mono.get(start..(start + n).min(len)).unwrap_or(&[]); + let pk = seg.iter().fold(0.0f32, |a, v| a.max(v.abs())); + let off = seg.iter().position(|v| v.abs() >= 0.25 * pk).unwrap_or(n / 2); + let pos = start + off; + if out.last().is_none_or(|&p: &usize| pos > p) { + out.push(pos); + } + } + } + out +} + +/// Strip Silence: ranges `(start, end)` (end exclusive) of material whose peak across channels +/// exceeds `threshold_db`. Silent gaps shorter than `min_len` samples do not split a region. +/// Each region is padded by `pad_before`/`pad_after` (clamped to the buffer) and overlapping +/// regions are merged. +pub fn non_silent_ranges(ch: &[Vec], threshold_db: f32, min_len: usize, pad_before: usize, pad_after: usize) -> Vec<(usize, usize)> { + let len = max_len(ch); + let thr = db_to_gain(if threshold_db.is_nan() { -48.0 } else { threshold_db }); + let mut raw: Vec<(usize, usize)> = Vec::new(); + let mut start: Option = None; + let mut last_loud = 0usize; + for i in 0..len { + let loud = ch.iter().any(|c| c.get(i).is_some_and(|v| v.is_finite() && v.abs() > thr)); + if !loud { + continue; + } + match start { + Some(s) if i - last_loud > min_len.max(1) => { + raw.push((s, last_loud + 1)); + start = Some(i); + } + None => start = Some(i), + _ => {} + } + last_loud = i; + } + if let Some(s) = start { + raw.push((s, last_loud + 1)); + } + let mut out: Vec<(usize, usize)> = Vec::with_capacity(raw.len()); + for (s, e) in raw { + let s = s.saturating_sub(pad_before); + let e = e.saturating_add(pad_after).min(len); + match out.last_mut() { + Some(last) if s <= last.1 => last.1 = last.1.max(e), + _ => out.push((s, e)), + } + } + out +} + +/// Runs a plugin over whole buffers in blocks, compensating its latency so the output lines up +/// with the input. The buffer length is unchanged (any tail past the end is dropped; see +/// [`apply_plugin_with_tail`]). +pub fn apply_plugin(ch: &mut [Vec], plugin: &mut dyn Plugin, sample_rate: f32) { + let len = max_len(ch); + let out = render(ch, plugin, sample_rate, len); + for (c, o) in ch.iter_mut().zip(out) { + for (d, s) in c.iter_mut().zip(o) { + *d = s; + } + } +} + +/// Like [`apply_plugin`] but returns new buffers extended by the plugin's tail (capped at 60 s). +pub fn apply_plugin_with_tail(ch: &[Vec], plugin: &mut dyn Plugin, sample_rate: f32) -> Vec> { + let len = max_len(ch); + plugin.prepare(sane_sr(sample_rate), 1024, ch.len()); + let tail = plugin.tail_samples().min((sane_sr(sample_rate) * 60.0) as usize); + render(ch, plugin, sample_rate, (len + tail).min(MAX_OFFLINE_SAMPLES)) +} + +fn render(ch: &[Vec], plugin: &mut dyn Plugin, sample_rate: f32, out_len: usize) -> Vec> { + const BLOCK: usize = 1024; + let sr = sane_sr(sample_rate); + let channels = ch.len(); + plugin.prepare(sr, BLOCK, channels); + plugin.reset(); + let lat = plugin.latency().min(MAX_OFFLINE_SAMPLES); + let mut out: Vec> = (0..channels).map(|_| vec![0.0; out_len]).collect(); + let mut scratch: Vec> = (0..channels).map(|_| vec![0.0; BLOCK]).collect(); + let total = out_len + lat; + let mut done = 0usize; + while done < total { + let n = BLOCK.min(total - done); + for (s, c) in scratch.iter_mut().zip(ch) { + for (i, v) in s.iter_mut().enumerate() { + *v = if i < n { c.get(done + i).copied().unwrap_or(0.0) } else { 0.0 }; + } + } + plugin.process(&mut scratch, n); + for (s, o) in scratch.iter().zip(out.iter_mut()) { + for (i, v) in s.iter().take(n).enumerate() { + let idx = done + i; + if idx >= lat + && let Some(d) = o.get_mut(idx - lat) + { + *d = if v.is_finite() { *v } else { 0.0 }; + } + } + } + done += n; + } + out +} + +/// Peak level of the buffers in dBFS (-144 for silence). +pub fn peak_db(ch: &[Vec]) -> f32 { + gain_to_db(peak(ch)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fade_shapes_endpoints() { + for s in FadeShape::ALL { + assert!(s.gain(0.0).abs() < 1e-6, "{s:?}"); + assert!((s.gain(1.0) - 1.0).abs() < 1e-5, "{s:?}"); + assert!(s.gain(f32::NAN) == 0.0); + } + } + + #[test] + fn fade_hits_zero_at_edges() { + let mut ch = vec![vec![1.0f32; 100]]; + fade(&mut ch, 10, 10, FadeShape::Linear); + assert_eq!(ch[0][0], 0.0); + assert_eq!(ch[0][99], 0.0); + assert_eq!(ch[0][50], 1.0); + fade(&mut ch, 1000, 1000, FadeShape::SCurve); + } +} diff --git a/crates/dsp/src/osc.rs b/crates/dsp/src/osc.rs new file mode 100644 index 00000000..d8be0b4c --- /dev/null +++ b/crates/dsp/src/osc.rs @@ -0,0 +1,91 @@ +//! Band-limited (PolyBLEP) oscillators shared by the generator and the synth. + +use std::f32::consts::TAU; + +use crate::util::{PinkNoise, Rng}; + +/// Test-signal / oscillator waveforms. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum Waveform { + Sine, + Square, + Saw, + Triangle, + WhiteNoise, + PinkNoise, +} + +impl Waveform { + pub const ALL: [Waveform; 6] = [Waveform::Sine, Waveform::Square, Waveform::Saw, Waveform::Triangle, Waveform::WhiteNoise, Waveform::PinkNoise]; + + /// Waveform for a choice index (out of range → Sine). + pub fn from_index(i: usize) -> Waveform { + Self::ALL.get(i).copied().unwrap_or(Waveform::Sine) + } +} + +#[inline] +fn poly_blep(t: f32, dt: f32) -> f32 { + if dt <= 0.0 { + 0.0 + } else if t < dt { + let t = t / dt; + 2.0 * t - t * t - 1.0 + } else if t > 1.0 - dt { + let t = (t - 1.0) / dt; + t * t + 2.0 * t + 1.0 + } else { + 0.0 + } +} + +/// Phase-accumulator oscillator. Output peaks at ±1 (noise is uniform ±1 / pink ≈ ±1). +#[derive(Debug, Clone, Copy)] +pub(crate) struct Osc { + pub(crate) phase: f32, + rng: Rng, + pink: PinkNoise, +} + +impl Osc { + pub(crate) fn new(seed: u32) -> Self { + Self { phase: 0.0, rng: Rng::new(seed), pink: PinkNoise::new() } + } + + pub(crate) fn reset(&mut self) { + self.phase = 0.0; + self.pink = PinkNoise::new(); + } + + /// Produces one sample; `inc` is frequency / sample rate. + #[inline] + pub(crate) fn next(&mut self, wave: Waveform, inc: f32) -> f32 { + let dt = if inc.is_finite() { inc.clamp(0.0, 0.49) } else { 0.0 }; + let t = self.phase; + let out = match wave { + Waveform::Sine => (t * TAU).sin(), + Waveform::Saw => 2.0 * t - 1.0 - poly_blep(t, dt), + Waveform::Square => { + let naive = if t < 0.5 { 1.0 } else { -1.0 }; + let t2 = if t + 0.5 >= 1.0 { t - 0.5 } else { t + 0.5 }; + naive + poly_blep(t, dt) - poly_blep(t2, dt) + } + Waveform::Triangle => { + // Starts at 0 rising, like a sine. + let u = t + 0.25; + let u = if u >= 1.0 { u - 1.0 } else { u }; + 1.0 - 4.0 * (u - 0.5).abs() + } + Waveform::WhiteNoise => self.rng.bipolar(), + Waveform::PinkNoise => { + let w = self.rng.bipolar(); + self.pink.next(w).clamp(-1.0, 1.0) + } + }; + self.phase += dt; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + out + } +} diff --git a/crates/dsp/src/pan.rs b/crates/dsp/src/pan.rs new file mode 100644 index 00000000..94ec196d --- /dev/null +++ b/crates/dsp/src/pan.rs @@ -0,0 +1,61 @@ +//! Mono-to-stereo pan laws. + +use std::f32::consts::FRAC_PI_4; + +/// Attenuation of a centered signal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum PanLaw { + /// Constant power: -3 dB at center. + #[default] + Minus3, + /// Compromise: -4.5 dB at center. + Minus4_5, + /// Constant voltage: -6 dB at center. + Minus6, + /// Balance: 0 dB at center, the far side fades out linearly. + Zero, +} + +impl PanLaw { + /// Center attenuation in dB (negative). + pub fn center_db(self) -> f32 { + match self { + PanLaw::Minus3 => -3.0, + PanLaw::Minus4_5 => -4.5, + PanLaw::Minus6 => -6.0, + PanLaw::Zero => 0.0, + } + } +} + +/// Left/right gains for `pan` in -1 (hard left) ..= 1 (hard right). NaN pans center. +pub fn gains(pan: f32, law: PanLaw) -> (f32, f32) { + let pan = if pan.is_nan() { 0.0 } else { pan.clamp(-1.0, 1.0) }; + if law == PanLaw::Zero { + return ((1.0 - pan).min(1.0), (1.0 + pan).min(1.0)); + } + let theta = (pan + 1.0) * FRAC_PI_4; + let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0)); + // cos(pi/4) = -3.0103 dB; raise to a power to reach the requested center level. + let exp = law.center_db() / -3.010_3; + let l = if pan <= -1.0 { 1.0 } else { l.powf(exp) }; + let r = if pan >= 1.0 { 1.0 } else { r.powf(exp) }; + (l, r) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gain_to_db; + + #[test] + fn center_levels() { + for law in [PanLaw::Minus3, PanLaw::Minus4_5, PanLaw::Minus6, PanLaw::Zero] { + let (l, r) = gains(0.0, law); + assert!((gain_to_db(l) - law.center_db()).abs() < 0.02, "{law:?}"); + assert!((l - r).abs() < 1e-6); + let (l, r) = gains(-1.0, law); + assert!((l - 1.0).abs() < 1e-6 && r.abs() < 1e-6); + } + } +} diff --git a/crates/dsp/src/params.rs b/crates/dsp/src/params.rs new file mode 100644 index 00000000..12b8522b --- /dev/null +++ b/crates/dsp/src/params.rs @@ -0,0 +1,95 @@ +//! Parameter storage and const constructors for static parameter tables. + +use crate::{ParamInfo, Taper, Unit}; + +/// A continuous parameter. +pub(crate) const fn p(id: &'static str, name: &'static str, min: f32, max: f32, default: f32, unit: Unit, taper: Taper) -> ParamInfo { + ParamInfo { id, name, min, max, default, unit, taper, choices: &[] } +} + +/// A linear-taper parameter. +pub(crate) const fn lin(id: &'static str, name: &'static str, min: f32, max: f32, default: f32, unit: Unit) -> ParamInfo { + p(id, name, min, max, default, unit, Taper::Linear) +} + +/// A log-taper parameter (frequencies, times, ratios). +pub(crate) const fn log(id: &'static str, name: &'static str, min: f32, max: f32, default: f32, unit: Unit) -> ParamInfo { + p(id, name, min, max, default, unit, Taper::Log) +} + +/// An on/off switch. +pub(crate) const fn toggle(id: &'static str, name: &'static str, default_on: bool) -> ParamInfo { + ParamInfo { id, name, min: 0.0, max: 1.0, default: if default_on { 1.0 } else { 0.0 }, unit: Unit::Toggle, taper: Taper::Linear, choices: &[] } +} + +/// A list choice; the value is the index. +pub(crate) const fn choice(id: &'static str, name: &'static str, choices: &'static [&'static str], default: usize) -> ParamInfo { + let max = if choices.is_empty() { 0.0 } else { (choices.len() - 1) as f32 }; + ParamInfo { id, name, min: 0.0, max, default: default as f32, unit: Unit::Choice, taper: Taper::Linear, choices } +} + +/// Current values for a plugin's parameter table (allocated once at construction). +#[derive(Debug, Clone)] +pub(crate) struct Params { + info: &'static [ParamInfo], + values: Vec, +} + +impl Params { + pub(crate) fn new(info: &'static [ParamInfo]) -> Self { + Self { info, values: info.iter().map(|p| p.default).collect() } + } + + /// Clamps and stores; returns the parameter index, or `None` for an unknown id. + pub(crate) fn set(&mut self, id: &str, value: f32) -> Option { + let idx = self.info.iter().position(|p| p.id == id)?; + let info = self.info.get(idx)?; + let slot = self.values.get_mut(idx)?; + *slot = info.clamp(value); + Some(idx) + } + + pub(crate) fn get(&self, id: &str) -> Option { + let idx = self.info.iter().position(|p| p.id == id)?; + self.values.get(idx).copied() + } + + /// Value by index (0.0 if out of range, which only a table/index mismatch could cause). + #[inline] + pub(crate) fn v(&self, idx: usize) -> f32 { + self.values.get(idx).copied().unwrap_or(0.0) + } + + #[inline] + pub(crate) fn on(&self, idx: usize) -> bool { + self.v(idx) >= 0.5 + } + + #[inline] + pub(crate) fn choice(&self, idx: usize) -> usize { + let v = self.v(idx); + if v.is_finite() && v > 0.0 { v.round() as usize } else { 0 } + } +} + +/// Implements `info`, `set_param` and `param` for a plugin struct with a `p: Params` field and an +/// `update(&mut self)` method that pushes new values to smoothers/coefficients. +macro_rules! param_plumbing { + ($info:expr) => { + fn info(&self) -> &'static $crate::PluginInfo { + &$info + } + fn set_param(&mut self, id: &str, value: f32) -> bool { + if self.p.set(id, value).is_some() { + self.update(); + true + } else { + false + } + } + fn param(&self, id: &str) -> Option { + self.p.get(id) + } + }; +} +pub(crate) use param_plumbing; diff --git a/crates/dsp/src/plugins/drums.rs b/crates/dsp/src/plugins/drums.rs new file mode 100644 index 00000000..75a7976c --- /dev/null +++ b/crates/dsp/src/plugins/drums.rs @@ -0,0 +1,400 @@ +//! Drum Synth: fully synthesized kit on a General-MIDI-style note map. +//! +//! 35/36 kick, 37/39 clap, 38/40 snare, 42/44 closed hat (chokes open hat), 46 open hat, +//! 41/43 floor tom, 45 low tom, 47/48 mid tom, 50 high tom, 49/57 crash, 51/59 ride. + +use std::f32::consts::TAU; + +use super::synth::{EventKind, EventQueue, MAX_EVENTS}; +use crate::biquad::{Biquad, FilterType}; +use crate::params::{Params, lin, log, param_plumbing}; +use crate::util::{Rng, Smoother, frames_in, ms_to_samples, sane_channels, sane_sr}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain}; + +static DRUM_PARAMS: [ParamInfo; 11] = [ + lin("level", "Level", -48.0, 6.0, -6.0, Unit::Db), + lin("kick_tune", "Kick Tune", -12.0, 12.0, 0.0, Unit::Semitones), + log("kick_decay", "Kick Decay", 50.0, 2000.0, 450.0, Unit::Ms), + lin("snare_tune", "Snare Tune", -12.0, 12.0, 0.0, Unit::Semitones), + lin("snare_snappy", "Snare Snappy", 0.0, 100.0, 60.0, Unit::Percent), + log("snare_decay", "Snare Decay", 50.0, 1000.0, 220.0, Unit::Ms), + log("hat_decay", "Closed Hat Decay", 20.0, 500.0, 80.0, Unit::Ms), + log("open_hat_decay", "Open Hat Decay", 100.0, 3000.0, 600.0, Unit::Ms), + lin("tom_tune", "Tom Tune", -12.0, 12.0, 0.0, Unit::Semitones), + log("tom_decay", "Tom Decay", 50.0, 2000.0, 500.0, Unit::Ms), + log("cymbal_decay", "Cymbal Decay", 200.0, 6000.0, 2000.0, Unit::Ms), +]; + +pub static DRUM_INFO: PluginInfo = PluginInfo { + id: "drum_synth", + name: "Drum Synth", + short_name: "Drums", + category: Category::Instrument, + params: &DRUM_PARAMS, + is_instrument: true, + audiosuite: false, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Kind { + Kick, + Snare, + Clap, + ClosedHat, + OpenHat, + Tom(f32), + Crash, + Ride, +} + +fn kind_for(note: u8) -> Option { + Some(match note { + 35 | 36 => Kind::Kick, + 38 | 40 => Kind::Snare, + 37 | 39 => Kind::Clap, + 42 | 44 => Kind::ClosedHat, + 46 => Kind::OpenHat, + 41 | 43 => Kind::Tom(82.0), + 45 => Kind::Tom(110.0), + 47 | 48 => Kind::Tom(147.0), + 50 => Kind::Tom(196.0), + 49 | 57 => Kind::Crash, + 51 | 59 => Kind::Ride, + _ => return None, + }) +} + +/// Inharmonic partials (Hz) for the metallic cymbal/hat oscillator bank. +const METAL: [f32; 6] = [263.0, 400.0, 421.0, 474.0, 587.0, 845.0]; +const VOICES: usize = 24; +const OFF: f32 = 1.0e-4; + +#[derive(Debug, Clone, Copy)] +struct DrumVoice { + kind: Option, + vel: f32, + t: usize, + amp: f32, + amp_coef: f32, + noise_amp: f32, + noise_coef: f32, + pitch_env: f32, + pitch_coef: f32, + f_start: f32, + f_end: f32, + phase: f32, + metal: [f32; 6], + filt: Biquad, + filt2: Biquad, + rng: Rng, + age: u64, +} + +impl DrumVoice { + fn new(seed: u32) -> Self { + Self { + kind: None, + vel: 0.0, + t: 0, + amp: 0.0, + amp_coef: 0.0, + noise_amp: 0.0, + noise_coef: 0.0, + pitch_env: 0.0, + pitch_coef: 0.0, + f_start: 0.0, + f_end: 0.0, + phase: 0.0, + metal: [0.0; 6], + filt: Biquad::default(), + filt2: Biquad::default(), + rng: Rng::new(seed), + age: 0, + } + } +} + +/// Coefficient that decays by 60 dB over `ms`. +fn decay60(ms: f32, sr: f32) -> f32 { + (0.001f32.ln() / ms_to_samples(ms, sr).max(1.0)).exp() +} + +pub struct DrumSynth { + p: Params, + sr: f32, + ch: usize, + voices: [DrumVoice; VOICES], + events: EventQueue, + counter: u64, + level: Smoother, +} + +impl Default for DrumSynth { + fn default() -> Self { + Self::new() + } +} + +impl DrumSynth { + pub fn new() -> Self { + let mut voices = [DrumVoice::new(1); VOICES]; + for (i, v) in voices.iter_mut().enumerate() { + *v = DrumVoice::new(0xBEEF + i as u32 * 977); + } + let mut d = + Self { p: Params::new(&DRUM_PARAMS), sr: 48_000.0, ch: 0, voices, events: EventQueue::new(), counter: 0, level: Smoother::new(0.5) }; + d.update(); + d + } + + fn update(&mut self) { + self.level.set(db_to_gain(self.p.v(0))); + } + + fn trigger(&mut self, note: u8, vel: u8) { + let Some(kind) = kind_for(note) else { return }; + if vel == 0 { + return; + } + let sr = self.sr; + if kind == Kind::ClosedHat { + let choke = decay60(15.0, sr); + for v in self.voices.iter_mut().filter(|v| v.kind == Some(Kind::OpenHat)) { + v.amp_coef = v.amp_coef.min(choke); + v.noise_coef = v.noise_coef.min(choke); + } + } + self.counter += 1; + let idx = self + .voices + .iter() + .position(|v| v.kind.is_none()) + .or_else(|| self.voices.iter().enumerate().min_by_key(|(_, v)| v.age).map(|(i, _)| i)) + .unwrap_or(0); + let v = |i| self.p.v(i); + let semis = |st: f32| 2f32.powf(st / 12.0); + let (kick_tune, kick_decay, snare_tune, snappy, snare_decay) = (semis(v(1)), v(2), semis(v(3)), v(4) / 100.0, v(5)); + let (hat, open, tom_tune, tom_decay, cym) = (v(6), v(7), semis(v(8)), v(9), v(10)); + let counter = self.counter; + let Some(dv) = self.voices.get_mut(idx) else { return }; + let seed = dv.rng.next_u32(); + *dv = DrumVoice::new(seed); + dv.kind = Some(kind); + dv.vel = f32::from(vel.min(127)) / 127.0; + dv.age = counter; + dv.amp = 1.0; + dv.pitch_env = 1.0; + match kind { + Kind::Kick => { + dv.f_start = 170.0 * kick_tune; + dv.f_end = 48.0 * kick_tune; + dv.pitch_coef = decay60(120.0, sr); + dv.amp_coef = decay60(kick_decay, sr); + dv.noise_amp = 0.5; + dv.noise_coef = decay60(6.0, sr); + dv.filt = Biquad::design(FilterType::LowPass, 3000.0, 0.7, 0.0, sr); + } + Kind::Snare => { + dv.f_start = 260.0 * snare_tune; + dv.f_end = 185.0 * snare_tune; + dv.pitch_coef = decay60(40.0, sr); + dv.amp_coef = decay60(snare_decay * 0.6, sr); + dv.noise_amp = 0.3 + snappy; + dv.noise_coef = decay60(snare_decay, sr); + dv.filt = Biquad::design(FilterType::HighPass, 1200.0, 0.7, 0.0, sr); + dv.filt2 = Biquad::design(FilterType::LowPass, 9000.0, 0.7, 0.0, sr); + } + Kind::Clap => { + dv.amp = 0.0; + dv.noise_amp = 1.0; + dv.noise_coef = decay60(250.0, sr); + dv.amp_coef = decay60(25.0, sr); + dv.filt = Biquad::design(FilterType::BandPass, 1150.0, 1.6, 0.0, sr); + } + Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => { + let (ms, hp, noise) = match kind { + Kind::ClosedHat => (hat, 7000.0, 0.6), + Kind::OpenHat => (open, 6500.0, 0.6), + Kind::Crash => (cym, 4500.0, 0.9), + _ => (cym * 0.6, 5500.0, 0.25), + }; + dv.amp_coef = decay60(ms, sr); + dv.noise_amp = noise; + dv.noise_coef = dv.amp_coef; + dv.filt = Biquad::design(FilterType::HighPass, hp, 0.7, 0.0, sr); + dv.filt2 = Biquad::design(FilterType::HighPass, hp, 0.7, 0.0, sr); + for (i, m) in dv.metal.iter_mut().enumerate() { + *m = i as f32 * 0.13; + } + } + Kind::Tom(base) => { + dv.f_start = base * 1.6 * tom_tune; + dv.f_end = base * tom_tune; + dv.pitch_coef = decay60(180.0, sr); + dv.amp_coef = decay60(tom_decay, sr); + dv.noise_amp = 0.15; + dv.noise_coef = decay60(10.0, sr); + dv.filt = Biquad::design(FilterType::LowPass, 4000.0, 0.7, 0.0, sr); + } + } + } + + #[inline] + fn render(&mut self) -> f32 { + let sr = self.sr; + let clap_gap = ms_to_samples(11.0, sr).max(1.0) as usize; + let mut out = 0.0; + for v in &mut self.voices { + let Some(kind) = v.kind else { continue }; + let y = match kind { + Kind::Kick | Kind::Tom(_) => { + v.pitch_env *= v.pitch_coef; + let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env; + v.phase += f / sr; + v.phase -= v.phase.floor(); + let tone = (v.phase * TAU).sin() * v.amp; + let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp; + v.amp *= v.amp_coef; + v.noise_amp *= v.noise_coef; + tone + click + } + Kind::Snare => { + v.pitch_env *= v.pitch_coef; + let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env; + v.phase += f / sr; + v.phase -= v.phase.floor(); + let tone = (v.phase * TAU).sin() * v.amp * 0.6; + let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7; + v.amp *= v.amp_coef; + v.noise_amp *= v.noise_coef; + tone + n + } + Kind::Clap => { + // Three quick bursts then a diffuse tail. + let burst = if v.t < clap_gap * 3 { + if v.t % clap_gap == 0 { + v.amp = 1.0; + } + v.amp + } else { + 0.0 + }; + let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 }); + let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0; + v.amp *= v.amp_coef; + if v.t >= clap_gap * 2 { + v.noise_amp *= v.noise_coef; + } + y + } + Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => { + let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 }; + let mut metal = 0.0; + for (m, f) in v.metal.iter_mut().zip(METAL) { + *m += f * scale / sr; + *m -= m.floor(); + metal += if *m < 0.5 { 1.0 } else { -1.0 }; + } + let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp; + let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp; + v.amp *= v.amp_coef; + y + } + }; + v.t += 1; + let alive = match kind { + Kind::Clap => v.t < clap_gap * 3 || v.noise_amp > OFF, + _ => v.amp > OFF || v.noise_amp * v.noise_coef > OFF && v.t < (sr * 0.05) as usize, + }; + if !alive { + v.kind = None; + } + out += y * v.vel; + } + out * 0.5 + } +} + +impl Plugin for DrumSynth { + param_plumbing!(DRUM_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.ch = sane_channels(channels); + self.level.set_time(20.0, self.sr); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + for v in &mut self.voices { + v.kind = None; + } + self.events.clear(); + self.level.snap(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.ch.min(io.len()); + let frames = frames_in(io, frames, ch); + let mut evs = [(0usize, EventKind::AllOff); MAX_EVENTS]; + let count = { + let sorted = self.events.sorted(); + for (d, s) in evs.iter_mut().zip(sorted) { + *d = (s.offset, s.kind); + } + sorted.len().min(MAX_EVENTS) + }; + self.events.clear(); + let mut next = 0; + for n in 0..frames { + while next < count { + let Some(&(off, kind)) = evs.get(next) else { break }; + if off > n && n + 1 < frames { + break; + } + self.apply(kind); + next += 1; + } + let y = self.render() * self.level.next(); + for b in io.iter_mut().take(ch) { + if let Some(x) = b.get_mut(n) { + *x = y; + } + } + } + for &(_, kind) in evs.iter().take(count).skip(next) { + self.apply(kind); + } + } + + fn tail_samples(&self) -> usize { + ms_to_samples(self.p.v(10).max(self.p.v(2)), self.sr) as usize + } + + fn note_on(&mut self, offset: usize, note: u8, velocity: u8) { + self.events.push(offset, EventKind::On(note, velocity)); + } + + fn note_off(&mut self, _offset: usize, _note: u8) {} + + fn all_notes_off(&mut self) { + self.events.push(0, EventKind::AllOff); + } +} + +impl DrumSynth { + fn apply(&mut self, kind: EventKind) { + match kind { + EventKind::On(n, v) => self.trigger(n, v), + EventKind::Off(_) => {} + EventKind::AllOff => { + let fast = decay60(30.0, self.sr); + for v in &mut self.voices { + v.amp_coef = v.amp_coef.min(fast); + v.noise_coef = v.noise_coef.min(fast); + } + } + } + } +} diff --git a/crates/dsp/src/plugins/dynamics.rs b/crates/dsp/src/plugins/dynamics.rs new file mode 100644 index 00000000..514dd334 --- /dev/null +++ b/crates/dsp/src/plugins/dynamics.rs @@ -0,0 +1,634 @@ +//! Dynamics: Compressor/Limiter, Expander/Gate, De-Esser and Maximizer. +//! +//! All detectors are stereo-linked (the loudest channel drives a shared gain) so the image does +//! not wander. Gain computers work in the log domain with smoothed gain reduction. + +use crate::biquad::{Biquad, Coeffs, FilterType}; +use crate::params::{Params, lin, log, param_plumbing, toggle}; +use crate::util::{DelayLine, Smoother, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain, gain_to_db}; + +/// Static soft-knee gain computer: returns the gain reduction amount in dB (≥ 0). +#[inline] +pub(crate) fn compress_gr(level_db: f32, threshold: f32, ratio: f32, knee: f32) -> f32 { + let slope = 1.0 - 1.0 / ratio.max(1.0); + let over = level_db - threshold; + if knee > 1e-3 && 2.0 * over.abs() <= knee { + let t = over + knee / 2.0; + slope * t * t / (2.0 * knee) + } else if over > 0.0 { + slope * over + } else { + 0.0 + } +} + +#[inline] +fn ballistic(state: f32, target: f32, att: f32, rel: f32) -> f32 { + let c = if target > state { att } else { rel }; + target + (state - target) * c +} + +// --------------------------------------------------------------------------------------------- + +static COMP_PARAMS: [ParamInfo; 11] = [ + lin("threshold", "Threshold", -60.0, 0.0, -20.0, Unit::Db), + log("ratio", "Ratio", 1.0, 100.0, 4.0, Unit::Ratio), + log("attack", "Attack", 0.01, 300.0, 10.0, Unit::Ms), + log("release", "Release", 5.0, 3000.0, 100.0, Unit::Ms), + lin("knee", "Knee", 0.0, 24.0, 6.0, Unit::Db), + lin("makeup", "Makeup", 0.0, 40.0, 0.0, Unit::Db), + toggle("sc_hpf_on", "SC HPF", false), + log("sc_hpf_freq", "SC HPF Freq", 20.0, 500.0, 100.0, Unit::Hz), + toggle("limiter", "Limit", false), + lin("lookahead", "Lookahead", 0.0, 10.0, 0.0, Unit::Ms), + lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent), +]; + +pub static COMP_INFO: PluginInfo = PluginInfo { + id: "compressor", + name: "Compressor/Limiter", + short_name: "Comp", + category: Category::Dynamics, + params: &COMP_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Feed-forward compressor / limiter with optional lookahead and sidechain high-pass. +pub struct Compressor { + p: Params, + sr: f32, + sc: Vec, + delay: Vec, + gr: f32, + att: f32, + rel: f32, + makeup: Smoother, + mix: Smoother, + lookahead: usize, + gr_meter: f32, +} + +impl Default for Compressor { + fn default() -> Self { + Self::new() + } +} + +impl Compressor { + pub fn new() -> Self { + let mut c = Self { + p: Params::new(&COMP_PARAMS), + sr: 48_000.0, + sc: Vec::new(), + delay: Vec::new(), + gr: 0.0, + att: 0.0, + rel: 0.0, + makeup: Smoother::new(1.0), + mix: Smoother::new(1.0), + lookahead: 0, + gr_meter: 0.0, + }; + c.update(); + c + } + + fn limiting(&self) -> bool { + self.p.on(8) + } + + fn update(&mut self) { + let sr = self.sr; + self.att = time_coef(self.p.v(2), sr); + self.rel = time_coef(self.p.v(3), sr); + self.makeup.set(db_to_gain(self.p.v(5))); + self.mix.set(self.p.v(10) / 100.0); + let f = self.p.v(7); + for b in &mut self.sc { + b.set(FilterType::HighPass, f, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr); + } + let max = self.delay.first().map(|d| d.max_delay() as usize).unwrap_or(0); + self.lookahead = (ms_to_samples(self.p.v(9), sr).round() as usize).min(max); + } + + /// Processes one frame of gain computation from a sidechain level (linear). + #[inline] + fn gain_for(&mut self, level: f32) -> f32 { + let ratio = if self.limiting() { 1000.0 } else { self.p.v(1) }; + let target = compress_gr(gain_to_db(level), self.p.v(0), ratio, self.p.v(4)); + self.gr = ballistic(self.gr, target, self.att, self.rel); + if !self.gr.is_finite() { + self.gr = 0.0; + } + self.gr + } +} + +impl Plugin for Compressor { + param_plumbing!(COMP_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + let ch = sane_channels(channels); + self.sc = vec![Biquad::default(); ch]; + let max = ms_to_samples(10.0, self.sr).ceil() as usize + 2; + self.delay = (0..ch) + .map(|_| { + let mut d = DelayLine::default(); + d.allocate(max); + d + }) + .collect(); + self.makeup.set_time(20.0, self.sr); + self.mix.set_time(20.0, self.sr); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.sc.iter_mut().for_each(Biquad::reset); + self.delay.iter_mut().for_each(DelayLine::clear); + self.gr = 0.0; + self.gr_meter = 0.0; + self.makeup.snap(); + self.mix.snap(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.sc.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let hpf = self.p.on(6); + let la = self.lookahead; + let mut max_gr = 0.0f32; + for n in 0..frames { + let mut level = 0.0f32; + for c in 0..ch { + let x = io.get(c).and_then(|b| b.get(n)).copied().unwrap_or(0.0); + let s = match self.sc.get_mut(c) { + Some(f) if hpf => f.process_sample(x), + _ => x, + }; + if s.is_finite() { + level = level.max(s.abs()); + } + } + let gr = self.gain_for(level); + max_gr = max_gr.max(gr); + let g = db_to_gain(-gr) * self.makeup.next(); + let m = self.mix.next(); + for c in 0..ch { + let (Some(buf), Some(d)) = (io.get_mut(c), self.delay.get_mut(c)) else { continue }; + let Some(x) = buf.get_mut(n) else { continue }; + d.push(*x); + let dry = if la == 0 { *x } else { d.tap(la + 1) }; + *x = dry * (1.0 - m) + dry * g * m; + } + } + self.gr_meter = max_gr; + } + + fn latency(&self) -> usize { + self.lookahead + } + + fn gain_reduction_db(&self) -> f32 { + self.gr_meter + } +} + +// --------------------------------------------------------------------------------------------- + +static GATE_PARAMS: [ParamInfo; 6] = [ + lin("threshold", "Threshold", -80.0, 0.0, -40.0, Unit::Db), + log("ratio", "Ratio", 1.0, 100.0, 100.0, Unit::Ratio), + lin("range", "Range", 0.0, 80.0, 80.0, Unit::Db), + log("attack", "Attack", 0.01, 100.0, 0.5, Unit::Ms), + lin("hold", "Hold", 0.0, 2000.0, 50.0, Unit::Ms), + log("release", "Release", 1.0, 4000.0, 100.0, Unit::Ms), +]; + +pub static GATE_INFO: PluginInfo = PluginInfo { + id: "expander_gate", + name: "Expander/Gate", + short_name: "Gate", + category: Category::Dynamics, + params: &GATE_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Downward expander / gate with hold. +pub struct ExpanderGate { + p: Params, + sr: f32, + ch: usize, + env: f32, + env_rel: f32, + gr: f32, + att: f32, + rel: f32, + hold: usize, + hold_left: usize, + gr_meter: f32, +} + +impl Default for ExpanderGate { + fn default() -> Self { + Self::new() + } +} + +impl ExpanderGate { + pub fn new() -> Self { + let mut g = Self { + p: Params::new(&GATE_PARAMS), + sr: 48_000.0, + ch: 0, + env: 0.0, + env_rel: 0.0, + gr: 0.0, + att: 0.0, + rel: 0.0, + hold: 0, + hold_left: 0, + gr_meter: 0.0, + }; + g.update(); + g + } + + fn update(&mut self) { + self.att = time_coef(self.p.v(3), self.sr); + self.rel = time_coef(self.p.v(5), self.sr); + self.hold = ms_to_samples(self.p.v(4), self.sr) as usize; + self.env_rel = time_coef(5.0, self.sr); + } + + /// Returns the gain reduction (dB ≥ 0) for one frame given the linked peak level. + #[inline] + pub(crate) fn tick(&mut self, level: f32) -> f32 { + let level = if level.is_finite() { level } else { 0.0 }; + self.env = level.max(self.env * self.env_rel); + let ldb = gain_to_db(self.env); + let thr = self.p.v(0); + let mut target = if ldb < thr { ((thr - ldb) * (self.p.v(1) - 1.0)).min(self.p.v(2)) } else { 0.0 }; + if target <= 0.0 { + self.hold_left = self.hold; + } else if target > self.gr && self.hold_left > 0 { + self.hold_left -= 1; + target = self.gr; + } + // Opening (less reduction) uses attack, closing uses release. + let c = if target < self.gr { self.att } else { self.rel }; + self.gr = target + (self.gr - target) * c; + if !self.gr.is_finite() { + self.gr = 0.0; + } + self.gr + } +} + +impl Plugin for ExpanderGate { + param_plumbing!(GATE_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.ch = sane_channels(channels); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.env = 0.0; + self.gr = 0.0; + self.hold_left = 0; + self.gr_meter = 0.0; + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.ch.min(io.len()); + let frames = frames_in(io, frames, ch); + let mut max_gr = 0.0f32; + for n in 0..frames { + let level = io.iter().take(ch).filter_map(|b| b.get(n)).fold(0.0f32, |m, v| m.max(v.abs())); + let gr = self.tick(level); + max_gr = max_gr.max(gr); + let g = db_to_gain(-gr); + for b in io.iter_mut().take(ch) { + if let Some(x) = b.get_mut(n) { + *x *= g; + } + } + } + self.gr_meter = max_gr; + } + + fn gain_reduction_db(&self) -> f32 { + self.gr_meter + } +} + +// --------------------------------------------------------------------------------------------- + +static DEESS_PARAMS: [ParamInfo; 4] = [ + log("freq", "Freq", 2000.0, 16000.0, 6000.0, Unit::Hz), + lin("range", "Range", 0.0, 40.0, 10.0, Unit::Db), + lin("threshold", "Threshold", -60.0, 0.0, -30.0, Unit::Db), + toggle("listen", "Listen", false), +]; + +pub static DEESS_INFO: PluginInfo = PluginInfo { + id: "de_esser", + name: "De-Esser", + short_name: "DeEss", + category: Category::Dynamics, + params: &DEESS_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Dynamic-EQ de-esser: a high-pass sidechain detects sibilance and a high shelf at `freq` +/// dips by up to `range` dB while it exceeds the threshold. At rest the shelf is flat, so the +/// signal passes untouched. +pub struct DeEsser { + p: Params, + sr: f32, + hp: Vec, + shelf: Vec, + env: f32, + att: f32, + rel: f32, + shelf_gr: f32, + gr_meter: f32, +} + +impl Default for DeEsser { + fn default() -> Self { + Self::new() + } +} + +impl DeEsser { + pub fn new() -> Self { + let mut d = Self { + p: Params::new(&DEESS_PARAMS), + sr: 48_000.0, + hp: Vec::new(), + shelf: Vec::new(), + env: 0.0, + att: 0.0, + rel: 0.0, + shelf_gr: 0.0, + gr_meter: 0.0, + }; + d.update(); + d + } + + fn update(&mut self) { + self.att = time_coef(0.5, self.sr); + self.rel = time_coef(60.0, self.sr); + let f = self.p.v(0); + let sr = self.sr; + for b in &mut self.hp { + b.set(FilterType::HighPass, f, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr); + } + self.set_shelf(self.shelf_gr); + } + + fn set_shelf(&mut self, gr: f32) { + self.shelf_gr = gr; + let c = if gr < 0.01 { Coeffs::IDENTITY } else { Coeffs::design(FilterType::HighShelf, self.p.v(0) * 0.8, 0.8, -gr, self.sr) }; + for b in &mut self.shelf { + b.coeffs = c; + } + } +} + +impl Plugin for DeEsser { + param_plumbing!(DEESS_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + let ch = sane_channels(channels); + self.hp = vec![Biquad::default(); ch]; + self.shelf = vec![Biquad::default(); ch]; + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.hp.iter_mut().for_each(Biquad::reset); + self.shelf.iter_mut().for_each(Biquad::reset); + self.env = 0.0; + self.gr_meter = 0.0; + self.set_shelf(0.0); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + const CHUNK: usize = 16; + let ch = self.hp.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let (thr, range, listen) = (self.p.v(2), self.p.v(1), self.p.on(3)); + let mut max_gr = 0.0f32; + let mut n = 0; + while n < frames { + let len = CHUNK.min(frames - n); + for i in n..n + len { + let mut level = 0.0f32; + for c in 0..ch { + let (Some(b), Some(f)) = (io.get_mut(c), self.hp.get_mut(c)) else { continue }; + let Some(x) = b.get_mut(i) else { continue }; + let h = f.process_sample(*x); + level = level.max(h.abs()); + if listen { + *x = h; + } + } + self.env = ballistic(self.env, level, self.att, self.rel); + if !self.env.is_finite() { + self.env = 0.0; + } + } + let gr = (gain_to_db(self.env) - thr).clamp(0.0, range); + max_gr = max_gr.max(gr); + if (gr - self.shelf_gr).abs() > 0.05 || (gr == 0.0 && self.shelf_gr != 0.0) { + self.set_shelf(gr); + } + if !listen { + for (b, f) in io.iter_mut().zip(self.shelf.iter_mut()).take(ch) { + if let Some(seg) = b.get_mut(n..n + len) { + f.process_block(seg); + } + } + } + n += len; + } + self.gr_meter = max_gr; + } + + fn gain_reduction_db(&self) -> f32 { + self.gr_meter + } +} + +// --------------------------------------------------------------------------------------------- + +static MAX_PARAMS: [ParamInfo; 3] = [ + lin("threshold", "Threshold", -30.0, 0.0, 0.0, Unit::Db), + lin("ceiling", "Ceiling", -30.0, 0.0, -0.3, Unit::Db), + log("release", "Release", 1.0, 1000.0, 50.0, Unit::Ms), +]; + +pub static MAX_INFO: PluginInfo = PluginInfo { + id: "maximizer", + name: "Maximizer", + short_name: "Max", + category: Category::Dynamics, + params: &MAX_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +const MAX_LOOKAHEAD_MS: f32 = 1.5; + +/// Lookahead brickwall limiter: the input is driven by `-threshold` dB and never exceeds the +/// ceiling. Gain is a min-hold over the lookahead window followed by an equal-length moving +/// average, which ramps the gain down before each peak reaches the output. +pub struct Maximizer { + p: Params, + sr: f32, + la: usize, + delay: Vec, + req: Vec, + smooth: Vec, + idx: usize, + sum: f64, + held: f32, + rel: f32, + drive: Smoother, + ceiling: Smoother, + gr_meter: f32, +} + +impl Default for Maximizer { + fn default() -> Self { + Self::new() + } +} + +impl Maximizer { + pub fn new() -> Self { + let mut m = Self { + p: Params::new(&MAX_PARAMS), + sr: 48_000.0, + la: 1, + delay: Vec::new(), + req: vec![1.0], + smooth: vec![1.0], + idx: 0, + sum: 1.0, + held: 1.0, + rel: 0.0, + drive: Smoother::new(1.0), + ceiling: Smoother::new(1.0), + gr_meter: 0.0, + }; + m.update(); + m + } + + fn update(&mut self) { + self.drive.set(db_to_gain(-self.p.v(0))); + // The ceiling is a hard guarantee, so it changes immediately. + self.ceiling.set(db_to_gain(self.p.v(1))); + self.ceiling.snap(); + self.rel = time_coef(self.p.v(2), self.sr); + } +} + +impl Plugin for Maximizer { + param_plumbing!(MAX_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.la = (ms_to_samples(MAX_LOOKAHEAD_MS, self.sr).round() as usize).max(1); + self.delay = (0..sane_channels(channels)) + .map(|_| { + let mut d = DelayLine::default(); + d.allocate(self.la + 2); + d + }) + .collect(); + self.req = vec![1.0; self.la]; + self.smooth = vec![1.0; self.la]; + self.drive.set_time(20.0, self.sr); + self.ceiling.set_time(20.0, self.sr); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.delay.iter_mut().for_each(DelayLine::clear); + self.req.iter_mut().for_each(|v| *v = 1.0); + self.smooth.iter_mut().for_each(|v| *v = 1.0); + self.sum = self.la as f64; + self.held = 1.0; + self.idx = 0; + self.drive.snap(); + self.ceiling.snap(); + self.gr_meter = 0.0; + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.delay.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let la = self.la.max(1); + let mut min_gain = 1.0f32; + for n in 0..frames { + let drive = self.drive.next(); + let ceil = self.ceiling.next(); + let mut peak = 0.0f32; + for c in 0..ch { + let (Some(buf), Some(d)) = (io.get(c), self.delay.get_mut(c)) else { continue }; + let x = buf.get(n).copied().filter(|v| v.is_finite()).unwrap_or(0.0) * drive; + d.push(x); + peak = peak.max(x.abs()); + } + let req = if peak > ceil { ceil / peak } else { 1.0 }; + if let Some(r) = self.req.get_mut(self.idx) { + *r = req; + } + let hold = self.req.iter().copied().fold(1.0f32, f32::min); + self.held = if hold < self.held { hold } else { hold + (self.held - hold) * self.rel }; + if let Some(s) = self.smooth.get_mut(self.idx) { + self.sum += f64::from(self.held) - f64::from(*s); + *s = self.held; + } + self.idx = (self.idx + 1) % la; + if self.idx == 0 { + // Re-sum once per window to cancel floating-point drift. + self.sum = self.smooth.iter().map(|&v| f64::from(v)).sum(); + } + let g = ((self.sum / la as f64) as f32).clamp(0.0, 1.0); + min_gain = min_gain.min(g); + for c in 0..ch { + let (Some(buf), Some(d)) = (io.get_mut(c), self.delay.get(c)) else { continue }; + if let Some(x) = buf.get_mut(n) { + *x = (d.tap(la) * g).clamp(-ceil, ceil); + } + } + } + self.gr_meter = -gain_to_db(min_gain); + } + + fn latency(&self) -> usize { + self.la - 1 + } + + fn gain_reduction_db(&self) -> f32 { + self.gr_meter + } +} diff --git a/crates/dsp/src/plugins/eq.rs b/crates/dsp/src/plugins/eq.rs new file mode 100644 index 00000000..6aefe91c --- /dev/null +++ b/crates/dsp/src/plugins/eq.rs @@ -0,0 +1,313 @@ +//! Parametric equalizers: EQ 1-Band and EQ 7-Band (HPF, low shelf, three peaks, high shelf, LPF). + +use crate::biquad::{Biquad, Coeffs, FilterType, butterworth_qs}; +use crate::params::{Params, choice, lin, log, toggle}; +use crate::util::{Smoother, frames_in, sane_channels, sane_sr}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain}; + +const SLOPES: &[&str] = &["6 dB/oct", "12 dB/oct", "18 dB/oct", "24 dB/oct"]; + +static EQ7_PARAMS: [ParamInfo; 23] = [ + lin("input_gain", "Input", -24.0, 24.0, 0.0, Unit::Db), + toggle("hpf_on", "HPF", false), + log("hpf_freq", "HPF Freq", 10.0, 2000.0, 80.0, Unit::Hz), + choice("hpf_slope", "HPF Slope", SLOPES, 1), + log("low_shelf_freq", "LF Freq", 20.0, 1000.0, 100.0, Unit::Hz), + lin("low_shelf_gain", "LF Gain", -24.0, 24.0, 0.0, Unit::Db), + log("low_shelf_q", "LF Q", 0.3, 2.0, 0.707, Unit::None), + log("low_mid_freq", "LMF Freq", 20.0, 2000.0, 250.0, Unit::Hz), + lin("low_mid_gain", "LMF Gain", -24.0, 24.0, 0.0, Unit::Db), + log("low_mid_q", "LMF Q", 0.1, 10.0, 1.0, Unit::None), + log("mid_freq", "MF Freq", 100.0, 8000.0, 1000.0, Unit::Hz), + lin("mid_gain", "MF Gain", -24.0, 24.0, 0.0, Unit::Db), + log("mid_q", "MF Q", 0.1, 10.0, 1.0, Unit::None), + log("high_mid_freq", "HMF Freq", 500.0, 20000.0, 4000.0, Unit::Hz), + lin("high_mid_gain", "HMF Gain", -24.0, 24.0, 0.0, Unit::Db), + log("high_mid_q", "HMF Q", 0.1, 10.0, 1.0, Unit::None), + log("high_shelf_freq", "HF Freq", 1000.0, 20000.0, 8000.0, Unit::Hz), + lin("high_shelf_gain", "HF Gain", -24.0, 24.0, 0.0, Unit::Db), + log("high_shelf_q", "HF Q", 0.3, 2.0, 0.707, Unit::None), + toggle("lpf_on", "LPF", false), + log("lpf_freq", "LPF Freq", 1000.0, 20000.0, 18000.0, Unit::Hz), + choice("lpf_slope", "LPF Slope", SLOPES, 1), + lin("output_gain", "Output", -24.0, 24.0, 0.0, Unit::Db), +]; + +pub static EQ7_INFO: PluginInfo = PluginInfo { + id: "eq_7band", + name: "EQ 7-Band", + short_name: "EQ7", + category: Category::Eq, + params: &EQ7_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +const EQ1_TYPES: &[&str] = &["Peak", "Low Shelf", "High Shelf", "High Pass", "Low Pass", "Notch", "Band Pass"]; + +static EQ1_PARAMS: [ParamInfo; 5] = [ + choice("type", "Type", EQ1_TYPES, 0), + log("freq", "Freq", 20.0, 20000.0, 1000.0, Unit::Hz), + lin("gain", "Gain", -24.0, 24.0, 0.0, Unit::Db), + log("q", "Q", 0.1, 10.0, 1.0, Unit::None), + lin("output_gain", "Output", -24.0, 24.0, 0.0, Unit::Db), +]; + +pub static EQ1_INFO: PluginInfo = PluginInfo { + id: "eq_1band", + name: "EQ 1-Band", + short_name: "EQ1", + category: Category::Eq, + params: &EQ1_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +#[inline] +fn val(v: &[f32], i: usize) -> f32 { + v.get(i).copied().unwrap_or(0.0) +} + +/// Writes 1–2 cascaded sections for an HPF/LPF of the given slope index (6/12/18/24 dB/oct). +fn pass_filter(slope: usize, high: bool, freq: f32, sr: f32) -> [Coeffs; 2] { + let (k1, k2) = if high { (FilterType::HighPass1, FilterType::HighPass) } else { (FilterType::LowPass1, FilterType::LowPass) }; + match slope { + 0 => [Coeffs::design(k1, freq, 0.707, 0.0, sr), Coeffs::IDENTITY], + 2 => [Coeffs::design(k1, freq, 0.707, 0.0, sr), Coeffs::design(k2, freq, 1.0, 0.0, sr)], + 3 => { + let q = butterworth_qs(4); + let q0 = q.first().copied().unwrap_or(0.54); + let q1 = q.get(1).copied().unwrap_or(1.31); + [Coeffs::design(k2, freq, q0, 0.0, sr), Coeffs::design(k2, freq, q1, 0.0, sr)] + } + _ => [Coeffs::design(k2, freq, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr), Coeffs::IDENTITY], + } +} + +fn design_eq7(v: &[f32], sr: f32) -> [Coeffs; 9] { + let mut c = [Coeffs::IDENTITY; 9]; + if val(v, 1) >= 0.5 { + let [a, b] = pass_filter(val(v, 3).round() as usize, true, val(v, 2), sr); + c[0] = a; + c[1] = b; + } + let bands = [(4, FilterType::LowShelf), (7, FilterType::Peak), (10, FilterType::Peak), (13, FilterType::Peak), (16, FilterType::HighShelf)]; + for (k, (i, kind)) in bands.into_iter().enumerate() { + let g = val(v, i + 1); + if g.abs() > 1e-4 + && let Some(slot) = c.get_mut(2 + k) + { + *slot = Coeffs::design(kind, val(v, i), val(v, i + 2), g, sr); + } + } + if val(v, 19) >= 0.5 { + let [a, b] = pass_filter(val(v, 21).round() as usize, false, val(v, 20), sr); + c[7] = a; + c[8] = b; + } + c +} + +fn design_eq1(v: &[f32], sr: f32) -> [Coeffs; 1] { + let kind = match val(v, 0).round() as usize { + 1 => FilterType::LowShelf, + 2 => FilterType::HighShelf, + 3 => FilterType::HighPass, + 4 => FilterType::LowPass, + 5 => FilterType::Notch, + 6 => FilterType::BandPass, + _ => FilterType::Peak, + }; + let g = val(v, 2); + if kind == FilterType::Peak && g.abs() < 1e-4 { + return [Coeffs::IDENTITY]; + } + [Coeffs::design(kind, val(v, 1), val(v, 3), g, sr)] +} + +/// Generic smoothed EQ: `S` cascaded sections designed from the parameter values. +pub struct Eq { + info: &'static PluginInfo, + design: fn(&[f32], f32) -> [Coeffs; S], + gain_ids: &'static [usize], + p: Params, + sm: Vec, + vals: Vec, + coeffs: [Coeffs; S], + filt: Vec<[Biquad; S]>, + gain: Smoother, + sr: f32, +} + +/// EQ 7-Band. +pub type Eq7 = Eq<9>; +/// EQ 1-Band. +pub type Eq1 = Eq<1>; + +impl Eq7 { + pub fn new() -> Self { + Eq::build(&EQ7_INFO, design_eq7, &[0, 22]) + } +} + +impl Default for Eq7 { + fn default() -> Self { + Self::new() + } +} + +impl Eq1 { + pub fn new() -> Self { + Eq::build(&EQ1_INFO, design_eq1, &[4]) + } +} + +impl Default for Eq1 { + fn default() -> Self { + Self::new() + } +} + +impl Eq { + fn build(info: &'static PluginInfo, design: fn(&[f32], f32) -> [Coeffs; S], gain_ids: &'static [usize]) -> Self { + let p = Params::new(info.params); + let sm = info.params.iter().map(|i| Smoother::new(i.default)).collect(); + let vals = info.params.iter().map(|i| i.default).collect(); + let mut eq = + Self { info, design, gain_ids, p, sm, vals, coeffs: [Coeffs::IDENTITY; S], filt: Vec::new(), gain: Smoother::new(1.0), sr: 48_000.0 }; + eq.redesign(); + eq + } + + fn total_gain_db(&self, v: impl Fn(usize) -> f32) -> f32 { + self.gain_ids.iter().map(|&i| v(i)).sum() + } + + fn update(&mut self) { + for (i, (s, info)) in self.sm.iter_mut().zip(self.info.params).enumerate() { + s.set(self.p.v(i)); + if matches!(info.unit, Unit::Toggle | Unit::Choice) { + s.snap(); + } + } + let g = db_to_gain(self.total_gain_db(|i| self.p.v(i))); + self.gain.set(g); + } + + fn redesign(&mut self) { + for (v, s) in self.vals.iter_mut().zip(&self.sm) { + *v = s.value(); + } + self.coeffs = (self.design)(&self.vals, self.sr); + for f in &mut self.filt { + for (b, c) in f.iter_mut().zip(&self.coeffs) { + b.coeffs = *c; + } + } + } + + /// Magnitude response (dB, including input/output gain) of the current settings. + pub fn response_db(&self, freqs: &[f32], sample_rate: f32) -> Vec { + let coeffs = (self.design)(&(0..self.info.params.len()).map(|i| self.p.v(i)).collect::>(), sample_rate); + let g = self.total_gain_db(|i| self.p.v(i)); + freqs.iter().map(|&f| g + coeffs.iter().map(|c| c.magnitude_db(f, sample_rate)).sum::()).collect() + } +} + +impl Plugin for Eq { + fn info(&self) -> &'static PluginInfo { + self.info + } + + fn set_param(&mut self, id: &str, value: f32) -> bool { + if self.p.set(id, value).is_some() { + self.update(); + true + } else { + false + } + } + + fn param(&self, id: &str) -> Option { + self.p.get(id) + } + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + for s in &mut self.sm { + s.set_time(20.0, self.sr); + } + self.gain.set_time(20.0, self.sr); + self.filt = vec![[Biquad::default(); S]; sane_channels(channels)]; + self.update(); + self.sm.iter_mut().for_each(Smoother::snap); + self.gain.snap(); + self.redesign(); + } + + fn reset(&mut self) { + self.filt.iter_mut().flatten().for_each(Biquad::reset); + self.sm.iter_mut().for_each(Smoother::snap); + self.gain.snap(); + self.redesign(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + const CHUNK: usize = 32; + let frames = frames_in(io, frames, self.filt.len()); + let mut pos = 0; + while pos < frames { + let n = CHUNK.min(frames - pos); + if self.sm.iter().any(Smoother::settling) { + self.sm.iter_mut().for_each(|s| { + s.advance(n); + }); + self.redesign(); + } + let mut gains = [0.0f32; CHUNK]; + for g in gains.iter_mut().take(n) { + *g = self.gain.next(); + } + for (buf, f) in io.iter_mut().zip(self.filt.iter_mut()) { + let Some(seg) = buf.get_mut(pos..pos + n) else { continue }; + for (x, g) in seg.iter_mut().zip(&gains) { + let mut y = *x; + for b in f.iter_mut() { + y = b.process_sample(y); + } + *x = y * g; + } + } + pos += n; + } + } +} + +fn response_from( + info: &'static PluginInfo, + design: &dyn Fn(&[f32], f32) -> Vec, + gain_ids: &[usize], + params: &[(&str, f32)], + freqs: &[f32], + sr: f32, +) -> Vec { + let mut p = Params::new(info.params); + for (id, v) in params { + let _ = p.set(id, *v); + } + let vals: Vec = (0..info.params.len()).map(|i| p.v(i)).collect(); + let coeffs = design(&vals, sr); + let g: f32 = gain_ids.iter().map(|&i| p.v(i)).sum(); + freqs.iter().map(|&f| g + coeffs.iter().map(|c| c.magnitude_db(f, sr)).sum::()).collect() +} + +/// EQ 7-Band magnitude response in dB at `freqs`, for curve drawing. `params` overrides the +/// defaults by id; unknown ids are ignored. +pub fn eq7_response(params: &[(&str, f32)], freqs: &[f32], sr: f32) -> Vec { + response_from(&EQ7_INFO, &|v, sr| design_eq7(v, sr).to_vec(), &[0, 22], params, freqs, sr) +} + +/// EQ 1-Band magnitude response in dB at `freqs`. +pub fn eq1_response(params: &[(&str, f32)], freqs: &[f32], sr: f32) -> Vec { + response_from(&EQ1_INFO, &|v, sr| design_eq1(v, sr).to_vec(), &[4], params, freqs, sr) +} diff --git a/crates/dsp/src/plugins/harmonic.rs b/crates/dsp/src/plugins/harmonic.rs new file mode 100644 index 00000000..acc3f3b5 --- /dev/null +++ b/crates/dsp/src/plugins/harmonic.rs @@ -0,0 +1,309 @@ +//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction) and Rectifier. + +use crate::params::{Params, choice, lin, log, param_plumbing}; +use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain}; + +static SAT_PARAMS: [ParamInfo; 5] = [ + lin("drive", "Drive", 0.0, 36.0, 6.0, Unit::Db), + lin("bias", "Asymmetry", 0.0, 100.0, 0.0, Unit::Percent), + log("tone", "Tone", 1000.0, 20000.0, 20000.0, Unit::Hz), + lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent), + lin("output", "Output", -24.0, 12.0, 0.0, Unit::Db), +]; + +pub static SAT_INFO: PluginInfo = PluginInfo { + id: "saturator", + name: "Saturator", + short_name: "Satur", + category: Category::Harmonic, + params: &SAT_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Smooth tanh waveshaper, normalized so a full-scale input still peaks near full scale (more +/// drive = more harmonics and density). Asymmetry adds even harmonics (the DC is removed). +pub struct Saturator { + p: Params, + sr: f32, + dc: Vec, + lp: Vec, + drive: Smoother, + bias: Smoother, + mix: Smoother, + out: Smoother, + tone: f32, +} + +impl Default for Saturator { + fn default() -> Self { + Self::new() + } +} + +impl Saturator { + pub fn new() -> Self { + let mut s = Self { + p: Params::new(&SAT_PARAMS), + sr: 48_000.0, + dc: Vec::new(), + lp: Vec::new(), + drive: Smoother::new(1.0), + bias: Smoother::new(0.0), + mix: Smoother::new(1.0), + out: Smoother::new(1.0), + tone: 0.0, + }; + s.update(); + s + } + + fn update(&mut self) { + self.drive.set(db_to_gain(self.p.v(0))); + self.bias.set(self.p.v(1) / 100.0 * 0.5); + self.mix.set(self.p.v(3) / 100.0); + self.out.set(db_to_gain(self.p.v(4))); + let f = self.p.v(2); + self.tone = if f >= 19_999.0 { 0.0 } else { (-std::f32::consts::TAU * f / self.sr).exp() }; + } +} + +impl Plugin for Saturator { + param_plumbing!(SAT_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + let ch = sane_channels(channels); + self.dc = vec![DcBlocker::new(10.0, self.sr); ch]; + self.lp = vec![0.0; ch]; + for s in [&mut self.drive, &mut self.bias, &mut self.mix, &mut self.out] { + s.set_time(20.0, self.sr); + } + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.dc.iter_mut().for_each(DcBlocker::reset); + self.lp.iter_mut().for_each(|v| *v = 0.0); + for s in [&mut self.drive, &mut self.bias, &mut self.mix, &mut self.out] { + s.snap(); + } + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.dc.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let a = self.tone; + for n in 0..frames { + let g = self.drive.next(); + let b = self.bias.next(); + let m = self.mix.next(); + let o = self.out.next(); + let tb = b.tanh(); + let norm = 1.0 / ((g + b).tanh() - tb).max(1e-3); + for c in 0..ch { + let (Some(buf), Some(dc), Some(lp)) = (io.get_mut(c), self.dc.get_mut(c), self.lp.get_mut(c)) else { continue }; + let Some(x) = buf.get_mut(n) else { continue }; + let dry = if x.is_finite() { *x } else { 0.0 }; + let mut y = ((g * dry + b).tanh() - tb) * norm; + if b > 0.0 { + y = dc.process(y); + } + if a > 0.0 { + *lp = flush(y + (*lp - y) * a); + y = *lp; + } + *x = (dry * (1.0 - m) + y * m) * o; + } + } + } +} + +// --------------------------------------------------------------------------------------------- + +static LOFI_PARAMS: [ParamInfo; 4] = [ + lin("bits", "Bit Depth", 1.0, 24.0, 8.0, Unit::None), + log("sample_rate", "Sample Rate", 500.0, 48000.0, 11025.0, Unit::Hz), + lin("noise", "Noise", 0.0, 100.0, 0.0, Unit::Percent), + lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent), +]; + +pub static LOFI_INFO: PluginInfo = PluginInfo { + id: "lofi", + name: "Lo-Fi", + short_name: "LoFi", + category: Category::Harmonic, + params: &LOFI_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Bit crusher and sample-and-hold decimator with optional hiss. +pub struct LoFi { + p: Params, + sr: f32, + held: Vec, + phase: f32, + rng: Rng, + mix: Smoother, + noise: Smoother, +} + +impl Default for LoFi { + fn default() -> Self { + Self::new() + } +} + +impl LoFi { + pub fn new() -> Self { + let mut l = Self { + p: Params::new(&LOFI_PARAMS), + sr: 48_000.0, + held: Vec::new(), + phase: 1.0, + rng: Rng::new(77), + mix: Smoother::new(1.0), + noise: Smoother::new(0.0), + }; + l.update(); + l + } + + fn update(&mut self) { + self.mix.set(self.p.v(3) / 100.0); + let n = self.p.v(2) / 100.0; + self.noise.set(n * n * 0.1); + } +} + +impl Plugin for LoFi { + param_plumbing!(LOFI_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.held = vec![0.0; sane_channels(channels)]; + self.mix.set_time(20.0, self.sr); + self.noise.set_time(20.0, self.sr); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.held.iter_mut().for_each(|v| *v = 0.0); + self.phase = 1.0; + self.mix.snap(); + self.noise.snap(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.held.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let step = 2f32.powf(1.0 - self.p.v(0)); + let inc = (self.p.v(1) / self.sr).min(1.0); + for n in 0..frames { + let m = self.mix.next(); + let nz = self.noise.next(); + self.phase += inc; + let grab = self.phase >= 1.0; + if grab { + self.phase -= 1.0; + } + for c in 0..ch { + let (Some(buf), Some(h)) = (io.get_mut(c), self.held.get_mut(c)) else { continue }; + let Some(x) = buf.get_mut(n) else { continue }; + let dry = if x.is_finite() { *x } else { 0.0 }; + if grab { + let noisy = dry + if nz > 0.0 { self.rng.bipolar() * nz } else { 0.0 }; + *h = (noisy / step).round() * step; + } + *x = dry * (1.0 - m) + *h * m; + } + } + } +} + +// --------------------------------------------------------------------------------------------- + +static RECT_PARAMS: [ParamInfo; 3] = [ + choice("mode", "Mode", &["Half Wave", "Full Wave"], 1), + lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent), + lin("output", "Output", -24.0, 12.0, 0.0, Unit::Db), +]; + +pub static RECT_INFO: PluginInfo = PluginInfo { + id: "rectifier", + name: "Rectifier", + short_name: "Rectify", + category: Category::Harmonic, + params: &RECT_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Half/full-wave rectifier (octave-up harmonics) with DC removal. +pub struct Rectifier { + p: Params, + sr: f32, + dc: Vec, + mix: Smoother, + out: Smoother, +} + +impl Default for Rectifier { + fn default() -> Self { + Self::new() + } +} + +impl Rectifier { + pub fn new() -> Self { + let mut r = Self { p: Params::new(&RECT_PARAMS), sr: 48_000.0, dc: Vec::new(), mix: Smoother::new(1.0), out: Smoother::new(1.0) }; + r.update(); + r + } + + fn update(&mut self) { + self.mix.set(self.p.v(1) / 100.0); + self.out.set(db_to_gain(self.p.v(2))); + } +} + +impl Plugin for Rectifier { + param_plumbing!(RECT_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.dc = vec![DcBlocker::new(10.0, self.sr); sane_channels(channels)]; + self.mix.set_time(20.0, self.sr); + self.out.set_time(20.0, self.sr); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.dc.iter_mut().for_each(DcBlocker::reset); + self.mix.snap(); + self.out.snap(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.dc.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let full = self.p.choice(0) == 1; + for n in 0..frames { + let m = self.mix.next(); + let o = self.out.next(); + for c in 0..ch { + let (Some(buf), Some(dc)) = (io.get_mut(c), self.dc.get_mut(c)) else { continue }; + let Some(x) = buf.get_mut(n) else { continue }; + let dry = if x.is_finite() { *x } else { 0.0 }; + let r = if full { dry.abs() } else { dry.max(0.0) }; + let wet = dc.process(r); + *x = (dry * (1.0 - m) + wet * m) * o; + } + } + } +} diff --git a/crates/dsp/src/plugins/mod.rs b/crates/dsp/src/plugins/mod.rs new file mode 100644 index 00000000..a1908353 --- /dev/null +++ b/crates/dsp/src/plugins/mod.rs @@ -0,0 +1,77 @@ +//! The built-in plugins and their registry. + +pub mod drums; +pub mod dynamics; +pub mod eq; +pub mod harmonic; +pub mod modulation; +pub mod pitch; +pub mod reverb; +pub mod strip; +pub mod synth; +pub mod utility; + +use crate::{Plugin, PluginInfo}; + +/// Every built-in plugin, in menu order. +pub(crate) static REGISTRY: &[&PluginInfo] = &[ + &eq::EQ1_INFO, + &eq::EQ7_INFO, + &dynamics::COMP_INFO, + &dynamics::GATE_INFO, + &dynamics::DEESS_INFO, + &dynamics::MAX_INFO, + &strip::STRIP_INFO, + &reverb::ROOM_INFO, + &reverb::PLATE_INFO, + &modulation::DELAY_INFO, + &modulation::CHORUS_INFO, + &modulation::FLANGER_INFO, + &modulation::PHASER_INFO, + &harmonic::SAT_INFO, + &harmonic::LOFI_INFO, + &harmonic::RECT_INFO, + &pitch::PITCH_INFO, + &utility::SHIFT_INFO, + &utility::GAIN_INFO, + &utility::TRIM_INFO, + &utility::INVERT_INFO, + &utility::DC_INFO, + &utility::GEN_INFO, + &utility::DITHER_INFO, + &synth::SYNTH_INFO, + &drums::DRUM_INFO, +]; + +/// Constructs an (unprepared) plugin by id. +pub(crate) fn instantiate(id: &str) -> Option> { + Some(match id { + "eq_1band" => Box::new(eq::Eq1::new()), + "eq_7band" => Box::new(eq::Eq7::new()), + "compressor" => Box::new(dynamics::Compressor::new()), + "expander_gate" => Box::new(dynamics::ExpanderGate::new()), + "de_esser" => Box::new(dynamics::DeEsser::new()), + "maximizer" => Box::new(dynamics::Maximizer::new()), + "channel_strip" => Box::new(strip::ChannelStrip::new()), + "room_reverb" => Box::new(reverb::Reverb::room()), + "plate_reverb" => Box::new(reverb::Reverb::plate()), + "mod_delay" => Box::new(modulation::ModDelay::new()), + "chorus" => Box::new(modulation::ModFx::chorus()), + "flanger" => Box::new(modulation::ModFx::flanger()), + "phaser" => Box::new(modulation::Phaser::new()), + "saturator" => Box::new(harmonic::Saturator::new()), + "lofi" => Box::new(harmonic::LoFi::new()), + "rectifier" => Box::new(harmonic::Rectifier::new()), + "pitch_shifter" => Box::new(pitch::PitchShifter::new()), + "time_shift" => Box::new(utility::TimeShift::new()), + "gain" => Box::new(utility::Gain::new()), + "trim" => Box::new(utility::Trim::new()), + "invert" => Box::new(utility::Invert::default()), + "dc_offset_removal" => Box::new(utility::DcRemoval::new()), + "signal_generator" => Box::new(utility::SignalGenerator::new()), + "dither" => Box::new(utility::Dither::new()), + "subtractive_synth" => Box::new(synth::SubtractiveSynth::new()), + "drum_synth" => Box::new(drums::DrumSynth::new()), + _ => return None, + }) +} diff --git a/crates/dsp/src/plugins/modulation.rs b/crates/dsp/src/plugins/modulation.rs new file mode 100644 index 00000000..38284f39 --- /dev/null +++ b/crates/dsp/src/plugins/modulation.rs @@ -0,0 +1,453 @@ +//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser. + +use crate::params::{Params, choice, lin, log, param_plumbing}; +use crate::util::{DelayLine, Lfo, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit}; + +/// Per-channel LFO phase offset for stereo movement (channel 1 is offset by `spread` cycles). +#[inline] +fn channel_phase(phase: f32, c: usize, spread: f32) -> f32 { + let p = phase + if c % 2 == 1 { spread } else { 0.0 }; + if p >= 1.0 { p - 1.0 } else { p } +} + +// --------------------------------------------------------------------------------------------- + +const MAX_DELAY_MS: f32 = 2000.0; +const MOD_DELAY_DEPTH_MS: f32 = 5.0; + +static DELAY_PARAMS: [ParamInfo; 6] = [ + log("time", "Time", 1.0, MAX_DELAY_MS, 250.0, Unit::Ms), + lin("feedback", "Feedback", -99.0, 99.0, 30.0, Unit::Percent), + lin("mix", "Mix", 0.0, 100.0, 30.0, Unit::Percent), + log("mod_rate", "Mod Rate", 0.01, 10.0, 0.5, Unit::Hz), + lin("mod_depth", "Mod Depth", 0.0, 100.0, 0.0, Unit::Percent), + log("lpf", "LPF", 500.0, 20000.0, 12000.0, Unit::Hz), +]; + +pub static DELAY_INFO: PluginInfo = PluginInfo { + id: "mod_delay", + name: "Mod Delay", + short_name: "ModDly", + category: Category::Delay, + params: &DELAY_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Feedback delay with modulation and a low-pass in the feedback path. +pub struct ModDelay { + p: Params, + sr: f32, + lines: Vec, + lp: Vec, + lfo: Lfo, + time: Smoother, + fb: Smoother, + mix: Smoother, + depth: Smoother, + lp_coef: f32, +} + +impl Default for ModDelay { + fn default() -> Self { + Self::new() + } +} + +impl ModDelay { + pub fn new() -> Self { + let mut d = Self { + p: Params::new(&DELAY_PARAMS), + sr: 48_000.0, + lines: Vec::new(), + lp: Vec::new(), + lfo: Lfo::new(), + time: Smoother::new(250.0), + fb: Smoother::new(0.0), + mix: Smoother::new(0.0), + depth: Smoother::new(0.0), + lp_coef: 0.0, + }; + d.update(); + d + } + + fn update(&mut self) { + self.time.set(self.p.v(0)); + self.fb.set(self.p.v(1) / 100.0); + self.mix.set(self.p.v(2) / 100.0); + self.depth.set(self.p.v(4) / 100.0 * MOD_DELAY_DEPTH_MS); + self.lp_coef = (-std::f32::consts::TAU * self.p.v(5) / self.sr).exp(); + } +} + +impl Plugin for ModDelay { + param_plumbing!(DELAY_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + let max = ms_to_samples(MAX_DELAY_MS + 2.0 * MOD_DELAY_DEPTH_MS, self.sr) as usize + 8; + self.lines = (0..sane_channels(channels)) + .map(|_| { + let mut d = DelayLine::default(); + d.allocate(max); + d + }) + .collect(); + self.lp = vec![0.0; self.lines.len()]; + self.time.set_time(120.0, self.sr); + for s in [&mut self.fb, &mut self.mix, &mut self.depth] { + s.set_time(20.0, self.sr); + } + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.lines.iter_mut().for_each(DelayLine::clear); + self.lp.iter_mut().for_each(|v| *v = 0.0); + self.lfo.reset(); + for s in [&mut self.time, &mut self.fb, &mut self.mix, &mut self.depth] { + s.snap(); + } + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.lines.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let inc = self.p.v(3) / self.sr; + let a = self.lp_coef; + for n in 0..frames { + let phase = self.lfo.tick(inc); + let t = self.time.next(); + let fb = self.fb.next(); + let m = self.mix.next(); + let depth = self.depth.next(); + for c in 0..ch { + let (Some(buf), Some(line), Some(lp)) = (io.get_mut(c), self.lines.get_mut(c), self.lp.get_mut(c)) else { continue }; + let Some(x) = buf.get_mut(n) else { continue }; + let dry = if x.is_finite() { *x } else { 0.0 }; + let modv = depth * 0.5 * (1.0 + Lfo::sin_at(channel_phase(phase, c, 0.25))); + let d = ms_to_samples(t + modv, self.sr); + let y = line.read_cubic(d); + *lp = flush(y + (*lp - y) * a); + line.push(flush(dry + fb * *lp)); + *x = dry * (1.0 - m) + *lp * m; + } + } + } + + fn tail_samples(&self) -> usize { + // Time for feedback to fall by 60 dB. + let fb = (self.p.v(1).abs() / 100.0).clamp(0.0, 0.99); + let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 }; + ms_to_samples(self.p.v(0) * repeats, self.sr) as usize + } +} + +// --------------------------------------------------------------------------------------------- + +static CHORUS_PARAMS: [ParamInfo; 6] = [ + log("rate", "Rate", 0.05, 5.0, 0.8, Unit::Hz), + lin("depth", "Depth", 0.0, 100.0, 50.0, Unit::Percent), + lin("delay", "Delay", 5.0, 30.0, 12.0, Unit::Ms), + lin("feedback", "Feedback", 0.0, 90.0, 0.0, Unit::Percent), + lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent), + lin("spread", "Spread", 0.0, 100.0, 100.0, Unit::Percent), +]; + +pub static CHORUS_INFO: PluginInfo = PluginInfo { + id: "chorus", + name: "Chorus", + short_name: "Chorus", + category: Category::Modulation, + params: &CHORUS_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +static FLANGER_PARAMS: [ParamInfo; 5] = [ + log("rate", "Rate", 0.02, 5.0, 0.25, Unit::Hz), + lin("depth", "Depth", 0.0, 100.0, 70.0, Unit::Percent), + lin("delay", "Delay", 0.1, 10.0, 2.0, Unit::Ms), + lin("feedback", "Feedback", -95.0, 95.0, 50.0, Unit::Percent), + lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent), +]; + +pub static FLANGER_INFO: PluginInfo = PluginInfo { + id: "flanger", + name: "Flanger", + short_name: "Flanger", + category: Category::Modulation, + params: &FLANGER_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +#[derive(Clone, Copy, PartialEq)] +enum ModKind { + Chorus, + Flanger, +} + +/// Modulated short delay used by both Chorus and Flanger. +pub struct ModFx { + kind: ModKind, + p: Params, + sr: f32, + lines: Vec, + fbs: Vec, + lfo: Lfo, + delay: Smoother, + depth: Smoother, + fb: Smoother, + mix: Smoother, +} + +impl ModFx { + pub fn chorus() -> Self { + Self::build(ModKind::Chorus) + } + + pub fn flanger() -> Self { + Self::build(ModKind::Flanger) + } + + fn build(kind: ModKind) -> Self { + let info = if kind == ModKind::Chorus { &CHORUS_INFO } else { &FLANGER_INFO }; + let mut m = Self { + kind, + p: Params::new(info.params), + sr: 48_000.0, + lines: Vec::new(), + fbs: Vec::new(), + lfo: Lfo::new(), + delay: Smoother::new(0.0), + depth: Smoother::new(0.0), + fb: Smoother::new(0.0), + mix: Smoother::new(0.0), + }; + m.update(); + m + } + + fn update(&mut self) { + self.delay.set(self.p.v(2)); + self.depth.set(self.p.v(1) / 100.0); + self.fb.set(self.p.v(3) / 100.0); + self.mix.set(self.p.v(4) / 100.0); + } +} + +impl Plugin for ModFx { + fn info(&self) -> &'static PluginInfo { + if self.kind == ModKind::Chorus { &CHORUS_INFO } else { &FLANGER_INFO } + } + + fn set_param(&mut self, id: &str, value: f32) -> bool { + if self.p.set(id, value).is_some() { + self.update(); + true + } else { + false + } + } + + fn param(&self, id: &str) -> Option { + self.p.get(id) + } + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + let max = ms_to_samples(45.0, self.sr) as usize + 8; + self.lines = (0..sane_channels(channels)) + .map(|_| { + let mut d = DelayLine::default(); + d.allocate(max); + d + }) + .collect(); + self.fbs = vec![0.0; self.lines.len()]; + for s in [&mut self.delay, &mut self.depth, &mut self.fb, &mut self.mix] { + s.set_time(30.0, self.sr); + } + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.lines.iter_mut().for_each(DelayLine::clear); + self.fbs.iter_mut().for_each(|v| *v = 0.0); + self.lfo.reset(); + for s in [&mut self.delay, &mut self.depth, &mut self.fb, &mut self.mix] { + s.snap(); + } + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.lines.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let inc = self.p.v(0) / self.sr; + let spread = if self.kind == ModKind::Chorus { 0.25 * self.p.v(5) / 100.0 } else { 0.25 }; + for n in 0..frames { + let phase = self.lfo.tick(inc); + let base = self.delay.next(); + let depth = self.depth.next(); + let fb = self.fb.next(); + let m = self.mix.next(); + for c in 0..ch { + let (Some(buf), Some(line), Some(fbs)) = (io.get_mut(c), self.lines.get_mut(c), self.fbs.get_mut(c)) else { continue }; + let Some(x) = buf.get_mut(n) else { continue }; + let dry = if x.is_finite() { *x } else { 0.0 }; + let s = Lfo::sin_at(channel_phase(phase, c, spread)); + let ms = match self.kind { + // Up to ±6 ms of sweep around the base delay. + ModKind::Chorus => base + depth * 6.0 * 0.5 * (1.0 + s), + // Sweep from (1 - depth)·delay up to delay. + ModKind::Flanger => (base * (1.0 - depth * 0.5 * (1.0 - s))).max(0.05), + }; + line.push(flush(dry + fb * *fbs)); + let y = line.read_cubic(ms_to_samples(ms, self.sr).max(2.0)); + *fbs = flush(y); + *x = dry * (1.0 - m) + y * m; + } + } + } + + fn tail_samples(&self) -> usize { + ms_to_samples(200.0, self.sr) as usize + } +} + +// --------------------------------------------------------------------------------------------- + +const STAGE_CHOICES: &[&str] = &["2", "4", "6", "8", "12"]; +const STAGE_COUNTS: [usize; 5] = [2, 4, 6, 8, 12]; +const MAX_STAGES: usize = 12; + +static PHASER_PARAMS: [ParamInfo; 6] = [ + log("rate", "Rate", 0.02, 5.0, 0.5, Unit::Hz), + lin("depth", "Depth", 0.0, 100.0, 70.0, Unit::Percent), + choice("stages", "Stages", STAGE_CHOICES, 2), + lin("feedback", "Feedback", -95.0, 95.0, 40.0, Unit::Percent), + log("center", "Center", 100.0, 4000.0, 800.0, Unit::Hz), + lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent), +]; + +pub static PHASER_INFO: PluginInfo = PluginInfo { + id: "phaser", + name: "Phaser", + short_name: "Phaser", + category: Category::Modulation, + params: &PHASER_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Cascade of modulated first-order allpasses with feedback. +pub struct Phaser { + p: Params, + sr: f32, + ch: usize, + z: Vec<[f32; MAX_STAGES]>, + fbs: Vec, + lfo: Lfo, + depth: Smoother, + fb: Smoother, + center: Smoother, + mix: Smoother, +} + +impl Default for Phaser { + fn default() -> Self { + Self::new() + } +} + +impl Phaser { + pub fn new() -> Self { + let mut p = Self { + p: Params::new(&PHASER_PARAMS), + sr: 48_000.0, + ch: 0, + z: Vec::new(), + fbs: Vec::new(), + lfo: Lfo::new(), + depth: Smoother::new(0.0), + fb: Smoother::new(0.0), + center: Smoother::new(800.0), + mix: Smoother::new(0.0), + }; + p.update(); + p + } + + fn update(&mut self) { + self.depth.set(self.p.v(1) / 100.0); + self.fb.set(self.p.v(3) / 100.0); + self.center.set(self.p.v(4)); + self.mix.set(self.p.v(5) / 100.0); + } +} + +impl Plugin for Phaser { + param_plumbing!(PHASER_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.ch = sane_channels(channels); + self.z = vec![[0.0; MAX_STAGES]; self.ch]; + self.fbs = vec![0.0; self.ch]; + for s in [&mut self.depth, &mut self.fb, &mut self.center, &mut self.mix] { + s.set_time(30.0, self.sr); + } + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.z.iter_mut().for_each(|z| *z = [0.0; MAX_STAGES]); + self.fbs.iter_mut().for_each(|v| *v = 0.0); + self.lfo.reset(); + for s in [&mut self.depth, &mut self.fb, &mut self.center, &mut self.mix] { + s.snap(); + } + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.ch.min(io.len()); + let frames = frames_in(io, frames, ch); + let inc = self.p.v(0) / self.sr; + let stages = STAGE_COUNTS.get(self.p.choice(2)).copied().unwrap_or(6); + let nyq = self.sr * 0.45; + for n in 0..frames { + let phase = self.lfo.tick(inc); + let depth = self.depth.next(); + let fb = self.fb.next(); + let center = self.center.next(); + let m = self.mix.next(); + for c in 0..ch { + let (Some(buf), Some(z), Some(fbs)) = (io.get_mut(c), self.z.get_mut(c), self.fbs.get_mut(c)) else { continue }; + let Some(x) = buf.get_mut(n) else { continue }; + let dry = if x.is_finite() { *x } else { 0.0 }; + let s = Lfo::sin_at(channel_phase(phase, c, 0.25)); + let f = (center * 2f32.powf(2.0 * depth * s)).clamp(20.0, nyq); + let t = (std::f32::consts::PI * f / self.sr).tan(); + let a = (t - 1.0) / (t + 1.0); + let mut y = dry + fb * *fbs; + for zi in z.iter_mut().take(stages) { + let out = a * y + *zi; + *zi = flush(y - a * out); + y = out; + } + *fbs = flush(y); + *x = dry * (1.0 - m) + y * m; + } + } + } + + fn tail_samples(&self) -> usize { + ms_to_samples(100.0, self.sr) as usize + } +} diff --git a/crates/dsp/src/plugins/pitch.rs b/crates/dsp/src/plugins/pitch.rs new file mode 100644 index 00000000..c9524b2d --- /dev/null +++ b/crates/dsp/src/plugins/pitch.rs @@ -0,0 +1,120 @@ +//! Realtime Pitch Shifter: two crossfaded read taps sweeping through a short delay (granular +//! "rotating tape head" design). Latency is half the grain window. + +use crate::params::{Params, lin, param_plumbing}; +use crate::util::{DelayLine, Smoother, frames_in, ms_to_samples, sane_channels, sane_sr}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit}; + +const WINDOW_MS: f32 = 50.0; + +static PITCH_PARAMS: [ParamInfo; 3] = [ + lin("semitones", "Semitones", -24.0, 24.0, 0.0, Unit::Semitones), + lin("cents", "Fine", -100.0, 100.0, 0.0, Unit::Cents), + lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent), +]; + +pub static PITCH_INFO: PluginInfo = PluginInfo { + id: "pitch_shifter", + name: "Pitch Shifter", + short_name: "Pitch", + category: Category::PitchShift, + params: &PITCH_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +pub struct PitchShifter { + p: Params, + sr: f32, + lines: Vec, + window: f32, + phase: f32, + shift: Smoother, + mix: Smoother, +} + +impl Default for PitchShifter { + fn default() -> Self { + Self::new() + } +} + +impl PitchShifter { + pub fn new() -> Self { + let mut p = Self { + p: Params::new(&PITCH_PARAMS), + sr: 48_000.0, + lines: Vec::new(), + window: 2400.0, + phase: 0.0, + shift: Smoother::new(0.0), + mix: Smoother::new(1.0), + }; + p.update(); + p + } + + fn update(&mut self) { + self.shift.set(self.p.v(0) + self.p.v(1) / 100.0); + self.mix.set(self.p.v(2) / 100.0); + } +} + +impl Plugin for PitchShifter { + param_plumbing!(PITCH_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.window = ms_to_samples(WINDOW_MS, self.sr).max(16.0); + self.lines = (0..sane_channels(channels)) + .map(|_| { + let mut d = DelayLine::default(); + d.allocate(self.window as usize + 8); + d + }) + .collect(); + self.shift.set_time(30.0, self.sr); + self.mix.set_time(20.0, self.sr); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.lines.iter_mut().for_each(DelayLine::clear); + self.phase = 0.0; + self.shift.snap(); + self.mix.snap(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.lines.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let w = self.window; + let half = w * 0.5 + 2.0; + for n in 0..frames { + let ratio = 2f32.powf(self.shift.next() / 12.0); + let m = self.mix.next(); + self.phase += (1.0 - ratio) / w; + self.phase -= self.phase.floor(); + if !self.phase.is_finite() { + self.phase = 0.0; + } + let p2 = if self.phase >= 0.5 { self.phase - 0.5 } else { self.phase + 0.5 }; + let g1 = (std::f32::consts::PI * self.phase).sin().powi(2); + let g2 = 1.0 - g1; + let (d1, d2) = (self.phase * w + 2.0, p2 * w + 2.0); + for c in 0..ch { + let (Some(buf), Some(line)) = (io.get_mut(c), self.lines.get_mut(c)) else { continue }; + let Some(x) = buf.get_mut(n) else { continue }; + line.push(if x.is_finite() { *x } else { 0.0 }); + let wet = g1 * line.read(d1) + g2 * line.read(d2); + let dry = line.read(half); + *x = dry * (1.0 - m) + wet * m; + } + } + } + + fn latency(&self) -> usize { + (self.window * 0.5 + 1.0).round() as usize + } +} diff --git a/crates/dsp/src/plugins/reverb.rs b/crates/dsp/src/plugins/reverb.rs new file mode 100644 index 00000000..d1f22a7b --- /dev/null +++ b/crates/dsp/src/plugins/reverb.rs @@ -0,0 +1,342 @@ +//! Room Reverb and Plate Reverb: pre-delay → allpass input diffusion → 8-line feedback delay +//! network (Hadamard mixing, per-line RT60 gains, in-loop HF damping, gentle delay modulation). + +use crate::biquad::{Biquad, FilterType}; +use crate::params::{Params, lin, log}; +use crate::util::{DelayLine, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit}; + +const LINES: usize = 8; +const DIFFUSERS: usize = 4; +const MAX_PREDELAY_MS: f32 = 250.0; +const SIZE_MIN: f32 = 0.35; +const SIZE_MAX: f32 = 1.6; + +struct Config { + delays_ms: [f32; LINES], + diffusers_ms: [f32; DIFFUSERS], + mod_ms: f32, + mod_hz: f32, +} + +const ROOM: Config = Config { + delays_ms: [23.13, 27.71, 31.87, 36.29, 41.33, 45.67, 51.09, 56.93], + diffusers_ms: [4.71, 3.59, 12.73, 9.31], + mod_ms: 0.25, + mod_hz: 0.7, +}; + +const PLATE: Config = + Config { delays_ms: [13.71, 17.93, 21.07, 24.73, 28.31, 32.89, 36.13, 40.31], diffusers_ms: [3.13, 4.27, 7.93, 11.29], mod_ms: 0.4, mod_hz: 1.1 }; + +macro_rules! reverb_params { + ($mix:expr, $pre:expr, $decay:expr, $damp:expr, $diff:expr) => { + [ + lin("mix", "Mix", 0.0, 100.0, $mix, Unit::Percent), + lin("pre_delay", "Pre-Delay", 0.0, MAX_PREDELAY_MS, $pre, Unit::Ms), + log("decay", "Decay", 0.1, 20.0, $decay, Unit::Seconds), + lin("size", "Size", 0.0, 100.0, 50.0, Unit::Percent), + log("damping", "HF Damping", 1000.0, 20000.0, $damp, Unit::Hz), + lin("diffusion", "Diffusion", 0.0, 100.0, $diff, Unit::Percent), + lin("width", "Width", 0.0, 100.0, 100.0, Unit::Percent), + log("low_cut", "Low Cut", 20.0, 1000.0, 20.0, Unit::Hz), + ] + }; +} + +static ROOM_PARAMS: [ParamInfo; 8] = reverb_params!(25.0, 10.0, 1.2, 6000.0, 70.0); +static PLATE_PARAMS: [ParamInfo; 8] = reverb_params!(30.0, 20.0, 2.5, 10000.0, 85.0); + +pub static ROOM_INFO: PluginInfo = PluginInfo { + id: "room_reverb", + name: "Room Reverb", + short_name: "Room", + category: Category::Reverb, + params: &ROOM_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +pub static PLATE_INFO: PluginInfo = PluginInfo { + id: "plate_reverb", + name: "Plate Reverb", + short_name: "Plate", + category: Category::Reverb, + params: &PLATE_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Schroeder allpass section with a fixed delay. +#[derive(Default)] +struct Allpass { + buf: Vec, + pos: usize, +} + +impl Allpass { + fn allocate(&mut self, len: usize) { + self.buf = vec![0.0; len.max(1)]; + self.pos = 0; + } + + #[inline] + fn process(&mut self, x: f32, g: f32) -> f32 { + let Some(slot) = self.buf.get_mut(self.pos) else { return x }; + let d = *slot; + let v = x + g * d; + *slot = flush(v); + self.pos += 1; + if self.pos >= self.buf.len() { + self.pos = 0; + } + d - g * v + } +} + +/// In-place normalized 8-point fast Walsh–Hadamard transform (an orthogonal mixing matrix). +#[inline] +fn hadamard8(v: &mut [f32; LINES]) { + let mut h = 1; + while h < LINES { + let mut i = 0; + while i < LINES { + for j in i..i + h { + let (a, b) = (v[j], v[j + h]); + v[j] = a + b; + v[j + h] = a - b; + } + i += h * 2; + } + h *= 2; + } + let s = 1.0 / (LINES as f32).sqrt(); + v.iter_mut().for_each(|x| *x *= s); +} + +/// FDN reverb shared by the room and plate models. +pub struct Reverb { + info: &'static PluginInfo, + cfg: &'static Config, + p: Params, + sr: f32, + ch: usize, + pre: [DelayLine; 2], + diff: [[Allpass; DIFFUSERS]; 2], + lines: [DelayLine; LINES], + lp: [f32; LINES], + gains: [f32; LINES], + damp: f32, + lowcut: [Biquad; 2], + mix: Smoother, + size: Smoother, + pre_ms: Smoother, + width: Smoother, + lfo: f32, +} + +impl Reverb { + pub fn room() -> Self { + Self::build(&ROOM_INFO, &ROOM) + } + + pub fn plate() -> Self { + Self::build(&PLATE_INFO, &PLATE) + } + + fn build(info: &'static PluginInfo, cfg: &'static Config) -> Self { + let mut r = Self { + info, + cfg, + p: Params::new(info.params), + sr: 48_000.0, + ch: 0, + pre: Default::default(), + diff: Default::default(), + lines: Default::default(), + lp: [0.0; LINES], + gains: [0.0; LINES], + damp: 0.0, + lowcut: [Biquad::default(); 2], + mix: Smoother::new(0.0), + size: Smoother::new(1.0), + pre_ms: Smoother::new(0.0), + width: Smoother::new(1.0), + lfo: 0.0, + }; + r.update(); + r.mix.snap(); + r.size.snap(); + r.pre_ms.snap(); + r.width.snap(); + r + } + + fn size_factor(&self) -> f32 { + SIZE_MIN + (SIZE_MAX - SIZE_MIN) * self.p.v(3) / 100.0 + } + + fn update(&mut self) { + self.mix.set(self.p.v(0) / 100.0); + self.pre_ms.set(self.p.v(1)); + self.size.set(self.size_factor()); + self.width.set(self.p.v(6) / 100.0); + let lc = self.p.v(7); + let sr = self.sr; + for b in &mut self.lowcut { + b.set(FilterType::HighPass, lc, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr); + } + self.damp = (-std::f32::consts::TAU * self.p.v(4) / sr).exp(); + self.update_gains(); + } + + /// Per-line feedback gain for the target RT60 at the current (smoothed) size. + fn update_gains(&mut self) { + let rt60 = self.p.v(2).max(0.05); + let size = self.size.value(); + for (g, ms) in self.gains.iter_mut().zip(self.cfg.delays_ms) { + let secs = ms * size * 0.001; + *g = 10f32.powf(-3.0 * secs / rt60).min(0.9999); + } + } +} + +impl Plugin for Reverb { + fn info(&self) -> &'static PluginInfo { + self.info + } + + fn set_param(&mut self, id: &str, value: f32) -> bool { + if self.p.set(id, value).is_some() { + self.update(); + true + } else { + false + } + } + + fn param(&self, id: &str) -> Option { + self.p.get(id) + } + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.ch = sane_channels(channels); + let sr = self.sr; + let pre_max = ms_to_samples(MAX_PREDELAY_MS, sr) as usize + 4; + self.pre.iter_mut().for_each(|d| d.allocate(pre_max)); + for (side, aps) in self.diff.iter_mut().enumerate() { + let spread = if side == 0 { 1.0 } else { 1.071 }; + for (ap, ms) in aps.iter_mut().zip(self.cfg.diffusers_ms) { + ap.allocate(ms_to_samples(ms * spread, sr) as usize); + } + } + let mod_max = ms_to_samples(self.cfg.mod_ms, sr) + 4.0; + for (l, ms) in self.lines.iter_mut().zip(self.cfg.delays_ms) { + l.allocate((ms_to_samples(ms * SIZE_MAX, sr) + mod_max) as usize); + } + self.mix.set_time(30.0, sr); + self.size.set_time(200.0, sr); + self.pre_ms.set_time(100.0, sr); + self.width.set_time(30.0, sr); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.pre.iter_mut().for_each(DelayLine::clear); + for aps in &mut self.diff { + for ap in aps.iter_mut() { + ap.buf.iter_mut().for_each(|v| *v = 0.0); + } + } + self.lines.iter_mut().for_each(DelayLine::clear); + self.lp = [0.0; LINES]; + self.lowcut.iter_mut().for_each(Biquad::reset); + self.mix.snap(); + self.size.snap(); + self.pre_ms.snap(); + self.width.snap(); + self.update_gains(); + self.lfo = 0.0; + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.ch.min(io.len()); + if ch == 0 { + return; + } + let frames = frames_in(io, frames, ch); + let sr = self.sr; + let g_diff = 0.7 * self.p.v(5) / 100.0; + let mod_depth = ms_to_samples(self.cfg.mod_ms, sr); + let lfo_inc = self.cfg.mod_hz / sr; + let damp = self.damp; + if self.size.settling() { + self.size.advance(frames); + self.update_gains(); + } + let size = self.size.value(); + let base: [f32; LINES] = self.cfg.delays_ms.map(|ms| ms_to_samples(ms * size, sr)); + for n in 0..frames { + let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0); + let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl }; + let pre = ms_to_samples(self.pre_ms.next(), sr); + let mut ins = [xl, xr]; + for (side, x) in ins.iter_mut().enumerate() { + let d = &mut self.pre[side]; + d.push(*x); + let mut v = if pre < 1.0 { *x } else { d.read(pre) }; + for ap in &mut self.diff[side] { + v = ap.process(v, g_diff); + } + *x = v; + } + self.lfo += lfo_inc; + if self.lfo >= 1.0 { + self.lfo -= 1.0; + } + let (s, c) = (self.lfo * std::f32::consts::TAU).sin_cos(); + let mods = [0.0, s, 0.0, c, 0.0, -s, 0.0, -c]; + let mut o = [0.0f32; LINES]; + for i in 0..LINES { + let raw = self.lines[i].read(base[i] + mod_depth * (1.0 + mods[i])); + self.lp[i] = flush(raw + (self.lp[i] - raw) * damp); + o[i] = raw; + } + let mut fb: [f32; LINES] = std::array::from_fn(|i| self.lp[i] * self.gains[i]); + hadamard8(&mut fb); + for (i, (line, f)) in self.lines.iter_mut().zip(fb).enumerate() { + let inj = if i % 2 == 0 { ins[0] } else { ins[1] }; + line.push(flush(f + inj * 0.5)); + } + let wl = 0.4 * (o[0] - o[2] + o[4] - o[6]); + let wr = 0.4 * (o[1] - o[3] + o[5] - o[7]); + let wl = self.lowcut[0].process_sample(wl); + let wr = self.lowcut[1].process_sample(wr); + let w = self.width.next(); + let mid = 0.5 * (wl + wr); + let side = 0.5 * (wl - wr) * w; + let (wl, wr) = (mid + side, mid - side); + let m = self.mix.next(); + for (c, b) in io.iter_mut().take(ch).enumerate() { + let wet = if ch == 1 { + mid + } else if c % 2 == 0 { + wl + } else { + wr + }; + if let Some(x) = b.get_mut(n) { + let dry = if x.is_finite() { *x } else { 0.0 }; + *x = dry * (1.0 - m) + wet * m; + } + } + } + } + + fn tail_samples(&self) -> usize { + let secs = self.p.v(2) * 1.3 + self.p.v(1) * 0.001 + 0.1; + (secs * self.sr) as usize + } +} diff --git a/crates/dsp/src/plugins/strip.rs b/crates/dsp/src/plugins/strip.rs new file mode 100644 index 00000000..daf8eddf --- /dev/null +++ b/crates/dsp/src/plugins/strip.rs @@ -0,0 +1,202 @@ +//! Channel Strip: input trim → gate → compressor → 3-band EQ with HPF → output. + +use super::dynamics::{Compressor, ExpanderGate}; +use super::eq::Eq7; +use crate::params::{Params, lin, log, toggle}; +use crate::util::{Smoother, frames_in, sane_channels, sane_sr}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain}; + +static STRIP_PARAMS: [ParamInfo; 22] = [ + lin("input_gain", "Input", -24.0, 24.0, 0.0, Unit::Db), + toggle("gate_on", "Gate", false), + lin("gate_threshold", "Gate Thresh", -80.0, 0.0, -50.0, Unit::Db), + lin("gate_range", "Gate Range", 0.0, 80.0, 40.0, Unit::Db), + log("gate_release", "Gate Release", 1.0, 4000.0, 150.0, Unit::Ms), + toggle("comp_on", "Comp", false), + lin("comp_threshold", "Comp Thresh", -60.0, 0.0, -18.0, Unit::Db), + log("comp_ratio", "Comp Ratio", 1.0, 100.0, 3.0, Unit::Ratio), + log("comp_attack", "Comp Attack", 0.01, 300.0, 10.0, Unit::Ms), + log("comp_release", "Comp Release", 5.0, 3000.0, 120.0, Unit::Ms), + lin("comp_makeup", "Comp Makeup", 0.0, 40.0, 0.0, Unit::Db), + toggle("eq_on", "EQ", true), + toggle("hpf_on", "HPF", false), + log("hpf_freq", "HPF Freq", 10.0, 2000.0, 80.0, Unit::Hz), + lin("low_gain", "Low Gain", -24.0, 24.0, 0.0, Unit::Db), + log("low_freq", "Low Freq", 20.0, 1000.0, 100.0, Unit::Hz), + lin("mid_gain", "Mid Gain", -24.0, 24.0, 0.0, Unit::Db), + log("mid_freq", "Mid Freq", 100.0, 8000.0, 1000.0, Unit::Hz), + log("mid_q", "Mid Q", 0.1, 10.0, 1.0, Unit::None), + lin("high_gain", "High Gain", -24.0, 24.0, 0.0, Unit::Db), + log("high_freq", "High Freq", 1000.0, 20000.0, 8000.0, Unit::Hz), + lin("output_gain", "Output", -24.0, 24.0, 0.0, Unit::Db), +]; + +pub static STRIP_INFO: PluginInfo = PluginInfo { + id: "channel_strip", + name: "Channel Strip", + short_name: "Strip", + category: Category::Dynamics, + params: &STRIP_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +#[derive(Clone, Copy)] +enum Target { + Gate, + Comp, + Eq, +} + +/// Parameter forwarding: strip index → (module, module parameter id). +const ROUTES: &[(usize, Target, &str)] = &[ + (2, Target::Gate, "threshold"), + (3, Target::Gate, "range"), + (4, Target::Gate, "release"), + (6, Target::Comp, "threshold"), + (7, Target::Comp, "ratio"), + (8, Target::Comp, "attack"), + (9, Target::Comp, "release"), + (10, Target::Comp, "makeup"), + (12, Target::Eq, "hpf_on"), + (13, Target::Eq, "hpf_freq"), + (14, Target::Eq, "low_shelf_gain"), + (15, Target::Eq, "low_shelf_freq"), + (16, Target::Eq, "mid_gain"), + (17, Target::Eq, "mid_freq"), + (18, Target::Eq, "mid_q"), + (19, Target::Eq, "high_shelf_gain"), + (20, Target::Eq, "high_shelf_freq"), +]; + +/// Gate + compressor + EQ in one insert. +pub struct ChannelStrip { + p: Params, + gate: ExpanderGate, + comp: Compressor, + eq: Eq7, + input: Smoother, + output: Smoother, + ch: usize, +} + +impl Default for ChannelStrip { + fn default() -> Self { + Self::new() + } +} + +impl ChannelStrip { + pub fn new() -> Self { + let mut s = Self { + p: Params::new(&STRIP_PARAMS), + gate: ExpanderGate::new(), + comp: Compressor::new(), + eq: Eq7::new(), + input: Smoother::new(1.0), + output: Smoother::new(1.0), + ch: 0, + }; + s.comp.set_param("knee", 6.0); + for &(idx, _, _) in ROUTES { + s.forward(idx); + } + s.update(); + s + } + + fn forward(&mut self, idx: usize) { + let v = self.p.v(idx); + for &(i, t, id) in ROUTES { + if i == idx { + match t { + Target::Gate => self.gate.set_param(id, v), + Target::Comp => self.comp.set_param(id, v), + Target::Eq => self.eq.set_param(id, v), + }; + } + } + } + + fn update(&mut self) { + self.input.set(db_to_gain(self.p.v(0))); + self.output.set(db_to_gain(self.p.v(21))); + } +} + +impl Plugin for ChannelStrip { + fn info(&self) -> &'static PluginInfo { + &STRIP_INFO + } + + fn set_param(&mut self, id: &str, value: f32) -> bool { + let Some(idx) = self.p.set(id, value) else { return false }; + self.forward(idx); + self.update(); + true + } + + fn param(&self, id: &str) -> Option { + self.p.get(id) + } + + fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize) { + let sr = sane_sr(sample_rate); + self.ch = sane_channels(channels); + self.gate.prepare(sr, max_block, channels); + self.comp.prepare(sr, max_block, channels); + self.eq.prepare(sr, max_block, channels); + self.input.set_time(20.0, sr); + self.output.set_time(20.0, sr); + self.reset(); + } + + fn reset(&mut self) { + self.gate.reset(); + self.comp.reset(); + self.eq.reset(); + self.input.snap(); + self.output.snap(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.ch.min(io.len()); + let frames = frames_in(io, frames, ch); + for n in 0..frames { + let g = self.input.next(); + for b in io.iter_mut().take(ch) { + if let Some(x) = b.get_mut(n) { + *x *= g; + } + } + } + if self.p.on(1) { + self.gate.process(io, frames); + } + if self.p.on(5) { + self.comp.process(io, frames); + } + if self.p.on(11) { + self.eq.process(io, frames); + } + for n in 0..frames { + let g = self.output.next(); + for b in io.iter_mut().take(ch) { + if let Some(x) = b.get_mut(n) { + *x *= g; + } + } + } + } + + fn gain_reduction_db(&self) -> f32 { + let mut gr = 0.0; + if self.p.on(1) { + gr += self.gate.gain_reduction_db(); + } + if self.p.on(5) { + gr += self.comp.gain_reduction_db(); + } + gr + } +} diff --git a/crates/dsp/src/plugins/synth.rs b/crates/dsp/src/plugins/synth.rs new file mode 100644 index 00000000..8e7f8c34 --- /dev/null +++ b/crates/dsp/src/plugins/synth.rs @@ -0,0 +1,473 @@ +//! Subtractive Synth: 16-voice polyphonic, two PolyBLEP oscillators, resonant state-variable +//! low-pass (TPT form) with its own ADSR, amp ADSR, optional glide. + +use crate::osc::{Osc, Waveform}; +use crate::params::{Params, choice, lin, log, param_plumbing}; +use crate::util::{Smoother, frames_in, ms_to_samples, note_hz, sane_channels, sane_sr, sort_events}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain}; + +pub(crate) const MAX_EVENTS: usize = 512; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum EventKind { + On(u8, u8), + Off(u8), + AllOff, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Event { + pub(crate) offset: usize, + pub(crate) kind: EventKind, +} + +/// Fixed-capacity event queue (never reallocates on the audio thread). +#[derive(Debug)] +pub(crate) struct EventQueue { + ev: Vec, +} + +impl EventQueue { + pub(crate) fn new() -> Self { + Self { ev: Vec::with_capacity(MAX_EVENTS) } + } + + pub(crate) fn push(&mut self, offset: usize, kind: EventKind) { + if self.ev.len() < MAX_EVENTS { + self.ev.push(Event { offset, kind }); + } else { + log::warn!("instrument event queue full; dropping event"); + } + } + + pub(crate) fn sorted(&mut self) -> &[Event] { + sort_events(&mut self.ev, |e| e.offset); + &self.ev + } + + pub(crate) fn clear(&mut self) { + self.ev.clear(); + } +} + +const WAVES: &[&str] = &["Saw", "Square", "Sine", "Triangle"]; + +fn wave(i: usize) -> Waveform { + match i { + 1 => Waveform::Square, + 2 => Waveform::Sine, + 3 => Waveform::Triangle, + _ => Waveform::Saw, + } +} + +static SYNTH_PARAMS: [ParamInfo; 19] = [ + choice("osc1_wave", "Osc 1", WAVES, 0), + choice("osc2_wave", "Osc 2", WAVES, 0), + lin("osc2_semitones", "Osc 2 Pitch", -24.0, 24.0, 0.0, Unit::Semitones), + lin("osc2_detune", "Osc 2 Detune", -100.0, 100.0, 7.0, Unit::Cents), + lin("osc_mix", "Osc Mix", 0.0, 100.0, 50.0, Unit::Percent), + log("cutoff", "Cutoff", 20.0, 20000.0, 2000.0, Unit::Hz), + lin("resonance", "Resonance", 0.0, 100.0, 20.0, Unit::Percent), + lin("filter_env_amount", "Filter Env", -100.0, 100.0, 30.0, Unit::Percent), + log("amp_attack", "Amp Attack", 0.5, 5000.0, 5.0, Unit::Ms), + log("amp_decay", "Amp Decay", 1.0, 5000.0, 200.0, Unit::Ms), + lin("amp_sustain", "Amp Sustain", 0.0, 100.0, 70.0, Unit::Percent), + log("amp_release", "Amp Release", 1.0, 10000.0, 300.0, Unit::Ms), + log("filter_attack", "Filter Attack", 0.5, 5000.0, 5.0, Unit::Ms), + log("filter_decay", "Filter Decay", 1.0, 5000.0, 300.0, Unit::Ms), + lin("filter_sustain", "Filter Sustain", 0.0, 100.0, 30.0, Unit::Percent), + log("filter_release", "Filter Release", 1.0, 10000.0, 300.0, Unit::Ms), + lin("glide", "Glide", 0.0, 1000.0, 0.0, Unit::Ms), + lin("velocity", "Velocity Sens", 0.0, 100.0, 70.0, Unit::Percent), + lin("level", "Level", -48.0, 6.0, -6.0, Unit::Db), +]; + +pub static SYNTH_INFO: PluginInfo = PluginInfo { + id: "subtractive_synth", + name: "Subtractive Synth", + short_name: "SubSynth", + category: Category::Instrument, + params: &SYNTH_PARAMS, + is_instrument: true, + audiosuite: false, +}; + +const VOICES: usize = 16; +const SILENT: f32 = 1.0e-5; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Stage { + Idle, + Attack, + Decay, + Sustain, + Release, +} + +/// Linear-attack, exponential decay/release ADSR. Times are "to -60 dB" for decay/release. +#[derive(Debug, Clone, Copy)] +struct Adsr { + stage: Stage, + level: f32, +} + +#[derive(Debug, Clone, Copy, Default)] +struct AdsrRates { + attack: f32, + decay: f32, + sustain: f32, + release: f32, +} + +impl AdsrRates { + fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self { + let exp60 = |ms: f32| (0.001f32.ln() / ms_to_samples(ms, sr).max(1.0)).exp(); + Self { attack: 1.0 / ms_to_samples(a_ms, sr).max(1.0), decay: exp60(d_ms), sustain: s.clamp(0.0, 1.0), release: exp60(r_ms) } + } +} + +impl Adsr { + const fn new() -> Self { + Self { stage: Stage::Idle, level: 0.0 } + } + + fn gate_on(&mut self) { + self.stage = Stage::Attack; + } + + fn gate_off(&mut self) { + if self.stage != Stage::Idle { + self.stage = Stage::Release; + } + } + + #[inline] + fn next(&mut self, r: &AdsrRates) -> f32 { + match self.stage { + Stage::Idle => self.level = 0.0, + Stage::Attack => { + self.level += r.attack; + if self.level >= 1.0 { + self.level = 1.0; + self.stage = Stage::Decay; + } + } + Stage::Decay => { + self.level = r.sustain + (self.level - r.sustain) * r.decay; + if (self.level - r.sustain).abs() < 1e-4 { + self.stage = Stage::Sustain; + } + } + Stage::Sustain => self.level = r.sustain, + Stage::Release => { + self.level *= r.release; + if self.level < SILENT { + self.level = 0.0; + self.stage = Stage::Idle; + } + } + } + self.level + } +} + +/// Topology-preserving-transform state-variable low-pass. +#[derive(Debug, Clone, Copy, Default)] +struct Svf { + ic1: f32, + ic2: f32, +} + +impl Svf { + #[inline] + fn lowpass(&mut self, x: f32, g: f32, k: f32) -> f32 { + let a1 = 1.0 / (1.0 + g * (g + k)); + let a2 = g * a1; + let a3 = g * a2; + let v3 = x - self.ic2; + let v1 = a1 * self.ic1 + a2 * v3; + let v2 = self.ic2 + a2 * self.ic1 + a3 * v3; + self.ic1 = crate::util::flush(2.0 * v1 - self.ic1); + self.ic2 = crate::util::flush(2.0 * v2 - self.ic2); + if v2.is_finite() { + v2 + } else { + *self = Svf::default(); + 0.0 + } + } +} + +#[derive(Debug, Clone, Copy)] +struct Voice { + note: u8, + vel: f32, + freq: f32, + target: f32, + osc1: Osc, + osc2: Osc, + amp: Adsr, + filt: Adsr, + svf: Svf, + age: u64, + held: bool, +} + +impl Voice { + fn new(seed: u32) -> Self { + Self { + note: 0, + vel: 0.0, + freq: 440.0, + target: 440.0, + osc1: Osc::new(seed), + osc2: Osc::new(seed.wrapping_mul(7919)), + amp: Adsr::new(), + filt: Adsr::new(), + svf: Svf::default(), + age: 0, + held: false, + } + } + + fn active(&self) -> bool { + self.amp.stage != Stage::Idle + } +} + +/// Polyphonic subtractive synthesizer. +pub struct SubtractiveSynth { + p: Params, + sr: f32, + ch: usize, + voices: [Voice; VOICES], + events: EventQueue, + amp_rates: AdsrRates, + filt_rates: AdsrRates, + glide_coef: f32, + last_freq: Option, + counter: u64, + level: Smoother, + cutoff: Smoother, +} + +impl Default for SubtractiveSynth { + fn default() -> Self { + Self::new() + } +} + +impl SubtractiveSynth { + pub fn new() -> Self { + let mut voices = [Voice::new(1); VOICES]; + for (i, v) in voices.iter_mut().enumerate() { + *v = Voice::new(0x1000 + i as u32 * 31); + } + let mut s = Self { + p: Params::new(&SYNTH_PARAMS), + sr: 48_000.0, + ch: 0, + voices, + events: EventQueue::new(), + amp_rates: AdsrRates::default(), + filt_rates: AdsrRates::default(), + glide_coef: 0.0, + last_freq: None, + counter: 0, + level: Smoother::new(0.5), + cutoff: Smoother::new(2000.0), + }; + s.update(); + s + } + + fn update(&mut self) { + let sr = self.sr; + let v = |i| self.p.v(i); + self.amp_rates = AdsrRates::new(v(8), v(9), v(10) / 100.0, v(11), sr); + self.filt_rates = AdsrRates::new(v(12), v(13), v(14) / 100.0, v(15), sr); + let glide = v(16); + self.glide_coef = if glide < 0.5 { 0.0 } else { crate::util::time_coef(glide / 3.0, sr) }; + self.level.set(db_to_gain(v(18))); + self.cutoff.set(v(5)); + } + + fn start_note(&mut self, note: u8, vel: u8) { + if vel == 0 { + self.stop_note(note); + return; + } + self.counter += 1; + let freq = note_hz(f32::from(note.min(127))); + // Reuse a voice already playing this note, else a free one, else steal. + let idx = self + .voices + .iter() + .position(|v| v.active() && v.note == note) + .or_else(|| self.voices.iter().position(|v| !v.active())) + .or_else(|| { + self.voices + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| { + let ka = (a.held, a.amp.level); + let kb = (b.held, b.amp.level); + ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(i, _)| i) + }) + .unwrap_or(0); + let sens = self.p.v(17) / 100.0; + let glide = self.glide_coef > 0.0; + let start = if glide { self.last_freq.unwrap_or(freq) } else { freq }; + let counter = self.counter; + if let Some(v) = self.voices.get_mut(idx) { + let was_active = v.active(); + v.note = note; + v.vel = (1.0 - sens) + sens * f32::from(vel.min(127)) / 127.0; + v.target = freq; + v.freq = if was_active && glide { v.freq } else { start }; + if !was_active { + v.osc1.reset(); + v.osc2.phase = 0.37; + v.svf = Svf::default(); + } + v.amp.gate_on(); + v.filt.gate_on(); + v.age = counter; + v.held = true; + } + self.last_freq = Some(freq); + } + + fn stop_note(&mut self, note: u8) { + for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) { + v.held = false; + v.amp.gate_off(); + v.filt.gate_off(); + } + } + + fn apply(&mut self, kind: EventKind) { + match kind { + EventKind::On(n, v) => self.start_note(n, v), + EventKind::Off(n) => self.stop_note(n), + EventKind::AllOff => { + for v in &mut self.voices { + v.held = false; + v.amp.gate_off(); + v.filt.gate_off(); + } + } + } + } + + #[inline] + fn render_sample(&mut self) -> f32 { + let sr = self.sr; + let w1 = wave(self.p.choice(0)); + let w2 = wave(self.p.choice(1)); + let ratio2 = 2f32.powf((self.p.v(2) + self.p.v(3) / 100.0) / 12.0); + let mix = self.p.v(4) / 100.0; + let k = 2.0 - 1.95 * (self.p.v(6) / 100.0); + let env_oct = self.p.v(7) / 100.0 * 6.0; + let cutoff = self.cutoff.next(); + let nyq = sr * 0.45; + let mut out = 0.0; + for v in &mut self.voices { + if !v.active() { + continue; + } + if self.glide_coef > 0.0 { + v.freq = v.target + (v.freq - v.target) * self.glide_coef; + } else { + v.freq = v.target; + } + let a = v.amp.next(&self.amp_rates); + let fe = v.filt.next(&self.filt_rates); + let s1 = v.osc1.next(w1, v.freq / sr); + let s2 = v.osc2.next(w2, v.freq * ratio2 / sr); + let raw = s1 * (1.0 - mix) + s2 * mix; + let fc = (cutoff * 2f32.powf(env_oct * fe)).clamp(20.0, nyq); + let g = (std::f32::consts::PI * fc / sr).tan(); + let y = v.svf.lowpass(raw, g, k); + out += y * a * v.vel; + } + out * 0.3 + } +} + +impl Plugin for SubtractiveSynth { + param_plumbing!(SYNTH_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.ch = sane_channels(channels); + self.level.set_time(20.0, self.sr); + self.cutoff.set_time(20.0, self.sr); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + for (i, v) in self.voices.iter_mut().enumerate() { + *v = Voice::new(0x1000 + i as u32 * 31); + } + self.events.clear(); + self.last_freq = None; + self.level.snap(); + self.cutoff.snap(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.ch.min(io.len()); + let frames = frames_in(io, frames, ch); + let mut evs = [Event { offset: 0, kind: EventKind::AllOff }; MAX_EVENTS]; + let count = { + let sorted = self.events.sorted(); + for (d, s) in evs.iter_mut().zip(sorted) { + *d = *s; + } + sorted.len().min(MAX_EVENTS) + }; + self.events.clear(); + let mut next = 0; + for n in 0..frames { + while let Some(e) = evs.get(next).filter(|_| next < count) { + if e.offset > n && n + 1 < frames { + break; + } + let kind = e.kind; + self.apply(kind); + next += 1; + } + let y = self.render_sample() * self.level.next(); + for b in io.iter_mut().take(ch) { + if let Some(x) = b.get_mut(n) { + *x = y; + } + } + } + // Events for an empty block take effect immediately. + for e in evs.iter().take(count).skip(next) { + self.apply(e.kind); + } + } + + fn tail_samples(&self) -> usize { + ms_to_samples(self.p.v(11), self.sr) as usize + } + + fn note_on(&mut self, offset: usize, note: u8, velocity: u8) { + self.events.push(offset, EventKind::On(note, velocity)); + } + + fn note_off(&mut self, offset: usize, note: u8) { + self.events.push(offset, EventKind::Off(note)); + } + + fn all_notes_off(&mut self) { + self.events.push(0, EventKind::AllOff); + } +} diff --git a/crates/dsp/src/plugins/utility.rs b/crates/dsp/src/plugins/utility.rs new file mode 100644 index 00000000..80d394e9 --- /dev/null +++ b/crates/dsp/src/plugins/utility.rs @@ -0,0 +1,512 @@ +//! Utilities: Time Shift, Gain, Trim, Invert, DC Offset Removal, Signal Generator, Dither. + +use crate::osc::{Osc, Waveform}; +use crate::params::{Params, choice, lin, log, param_plumbing, toggle}; +use crate::util::{DcBlocker, DelayLine, Rng, Smoother, frames_in, ms_to_samples, sane_channels, sane_sr}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain}; + +/// Gain parameter floor that means "-inf". +const OFF_DB: f32 = -96.0; + +fn fader_gain(db: f32) -> f32 { + if db <= OFF_DB { 0.0 } else { db_to_gain(db) } +} + +/// Applies a per-sample smoothed gain to all channels. +fn apply_gain(io: &mut [Vec], frames: usize, ch: usize, g: &mut Smoother) { + let ch = ch.min(io.len()); + let frames = frames_in(io, frames, ch); + if !g.settling() { + let v = g.value(); + if v == 1.0 { + return; + } + io.iter_mut().take(ch).for_each(|b| b.iter_mut().take(frames).for_each(|x| *x *= v)); + return; + } + for n in 0..frames { + let v = g.next(); + for b in io.iter_mut().take(ch) { + if let Some(x) = b.get_mut(n) { + *x *= v; + } + } + } +} + +// --------------------------------------------------------------------------------------------- + +const MAX_SHIFT_MS: f32 = 1000.0; +const MAX_SHIFT_SAMPLES: f32 = 10_000.0; +const XFADE: usize = 256; + +static SHIFT_PARAMS: [ParamInfo; 2] = + [lin("delay_ms", "Delay", 0.0, MAX_SHIFT_MS, 0.0, Unit::Ms), lin("delay_samples", "Samples", 0.0, MAX_SHIFT_SAMPLES, 0.0, Unit::None)]; + +pub static SHIFT_INFO: PluginInfo = PluginInfo { + id: "time_shift", + name: "Time Shift", + short_name: "TShift", + category: Category::Other, + params: &SHIFT_PARAMS, + is_instrument: false, + audiosuite: false, +}; + +/// Delays the signal by `delay_ms + delay_samples`; changes crossfade between taps (no clicks). +pub struct TimeShift { + p: Params, + sr: f32, + lines: Vec, + cur: usize, + target: usize, + fade: usize, +} + +impl Default for TimeShift { + fn default() -> Self { + Self::new() + } +} + +impl TimeShift { + pub fn new() -> Self { + Self { p: Params::new(&SHIFT_PARAMS), sr: 48_000.0, lines: Vec::new(), cur: 0, target: 0, fade: 0 } + } + + fn delay_samples(&self) -> usize { + let max = self.lines.first().map(|l| l.max_delay() as usize).unwrap_or(1).saturating_sub(2); + ((ms_to_samples(self.p.v(0), self.sr) + self.p.v(1).max(0.0)).round() as usize).min(max) + } + + fn update(&mut self) { + let d = self.delay_samples(); + if d != self.target { + if self.fade == 0 { + self.fade = XFADE; + } + self.target = d; + } + } +} + +impl Plugin for TimeShift { + param_plumbing!(SHIFT_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + let max = (ms_to_samples(MAX_SHIFT_MS, self.sr) + MAX_SHIFT_SAMPLES) as usize + 8; + self.lines = (0..sane_channels(channels)) + .map(|_| { + let mut d = DelayLine::default(); + d.allocate(max); + d + }) + .collect(); + self.reset(); + } + + fn reset(&mut self) { + self.lines.iter_mut().for_each(DelayLine::clear); + self.target = self.delay_samples(); + self.cur = self.target; + self.fade = 0; + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.lines.len().min(io.len()); + let frames = frames_in(io, frames, ch); + for n in 0..frames { + let t = if self.fade > 0 { 1.0 - self.fade as f32 / XFADE as f32 } else { 1.0 }; + for c in 0..ch { + let (Some(buf), Some(line)) = (io.get_mut(c), self.lines.get_mut(c)) else { continue }; + let Some(x) = buf.get_mut(n) else { continue }; + line.push(if x.is_finite() { *x } else { 0.0 }); + let new = line.tap(self.target + 1); + *x = if self.fade > 0 { line.tap(self.cur + 1) * (1.0 - t) + new * t } else { new }; + } + if self.fade > 0 { + self.fade -= 1; + if self.fade == 0 { + self.cur = self.target; + } + } + } + } + + fn tail_samples(&self) -> usize { + self.target + } +} + +// --------------------------------------------------------------------------------------------- + +static GAIN_PARAMS: [ParamInfo; 1] = [lin("gain", "Gain", OFF_DB, 24.0, 0.0, Unit::Db)]; + +pub static GAIN_INFO: PluginInfo = PluginInfo { + id: "gain", + name: "Gain", + short_name: "Gain", + category: Category::Other, + params: &GAIN_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Smoothed gain change (-inf .. +24 dB). +pub struct Gain { + p: Params, + ch: usize, + g: Smoother, +} + +impl Default for Gain { + fn default() -> Self { + Self::new() + } +} + +impl Gain { + pub fn new() -> Self { + Self { p: Params::new(&GAIN_PARAMS), ch: 0, g: Smoother::new(1.0) } + } + + fn update(&mut self) { + self.g.set(fader_gain(self.p.v(0))); + } +} + +impl Plugin for Gain { + param_plumbing!(GAIN_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.ch = sane_channels(channels); + self.g.set_time(20.0, sane_sr(sample_rate)); + self.update(); + self.g.snap(); + } + + fn reset(&mut self) { + self.g.snap(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + apply_gain(io, frames, self.ch, &mut self.g); + } +} + +// --------------------------------------------------------------------------------------------- + +static TRIM_PARAMS: [ParamInfo; 2] = [lin("gain", "Trim", OFF_DB, 12.0, 0.0, Unit::Db), toggle("invert", "Phase Invert", false)]; + +pub static TRIM_INFO: PluginInfo = PluginInfo { + id: "trim", + name: "Trim", + short_name: "Trim", + category: Category::Other, + params: &TRIM_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Trim (-inf .. +12 dB) with a click-free polarity switch. +pub struct Trim { + p: Params, + ch: usize, + g: Smoother, +} + +impl Default for Trim { + fn default() -> Self { + Self::new() + } +} + +impl Trim { + pub fn new() -> Self { + Self { p: Params::new(&TRIM_PARAMS), ch: 0, g: Smoother::new(1.0) } + } + + fn update(&mut self) { + let sign = if self.p.on(1) { -1.0 } else { 1.0 }; + self.g.set(sign * fader_gain(self.p.v(0))); + } +} + +impl Plugin for Trim { + param_plumbing!(TRIM_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.ch = sane_channels(channels); + self.g.set_time(10.0, sane_sr(sample_rate)); + self.update(); + self.g.snap(); + } + + fn reset(&mut self) { + self.g.snap(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + apply_gain(io, frames, self.ch, &mut self.g); + } +} + +// --------------------------------------------------------------------------------------------- + +pub static INVERT_INFO: PluginInfo = + PluginInfo { id: "invert", name: "Invert", short_name: "Invert", category: Category::Other, params: &[], is_instrument: false, audiosuite: true }; + +/// Polarity inversion. +#[derive(Default)] +pub struct Invert { + ch: usize, +} + +impl Plugin for Invert { + fn info(&self) -> &'static PluginInfo { + &INVERT_INFO + } + + fn prepare(&mut self, _sample_rate: f32, _max_block: usize, channels: usize) { + self.ch = sane_channels(channels); + } + + fn reset(&mut self) {} + + fn set_param(&mut self, _id: &str, _value: f32) -> bool { + false + } + + fn param(&self, _id: &str) -> Option { + None + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.ch.min(io.len()); + let frames = frames_in(io, frames, ch); + io.iter_mut().take(ch).for_each(|b| b.iter_mut().take(frames).for_each(|x| *x = -*x)); + } +} + +// --------------------------------------------------------------------------------------------- + +static DC_PARAMS: [ParamInfo; 1] = [log("cutoff", "Cutoff", 1.0, 40.0, 5.0, Unit::Hz)]; + +pub static DC_INFO: PluginInfo = PluginInfo { + id: "dc_offset_removal", + name: "DC Offset Removal", + short_name: "DC Rmv", + category: Category::Other, + params: &DC_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// One-pole DC-blocking high-pass. +pub struct DcRemoval { + p: Params, + sr: f32, + f: Vec, +} + +impl Default for DcRemoval { + fn default() -> Self { + Self::new() + } +} + +impl DcRemoval { + pub fn new() -> Self { + Self { p: Params::new(&DC_PARAMS), sr: 48_000.0, f: Vec::new() } + } + + fn update(&mut self) { + let (fc, sr) = (self.p.v(0), self.sr); + self.f.iter_mut().for_each(|f| f.set_cutoff(fc, sr)); + } +} + +impl Plugin for DcRemoval { + param_plumbing!(DC_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.f = vec![DcBlocker::new(self.p.v(0), self.sr); sane_channels(channels)]; + } + + fn reset(&mut self) { + self.f.iter_mut().for_each(DcBlocker::reset); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.f.len().min(io.len()); + let frames = frames_in(io, frames, ch); + for (b, f) in io.iter_mut().zip(self.f.iter_mut()) { + for x in b.iter_mut().take(frames) { + *x = f.process(if x.is_finite() { *x } else { 0.0 }); + } + } + } +} + +// --------------------------------------------------------------------------------------------- + +const WAVE_NAMES: &[&str] = &["Sine", "Square", "Saw", "Triangle", "White Noise", "Pink Noise"]; + +static GEN_PARAMS: [ParamInfo; 3] = [ + choice("waveform", "Waveform", WAVE_NAMES, 0), + log("freq", "Frequency", 20.0, 20000.0, 1000.0, Unit::Hz), + lin("level", "Level", OFF_DB, 0.0, -20.0, Unit::Db), +]; + +pub static GEN_INFO: PluginInfo = PluginInfo { + id: "signal_generator", + name: "Signal Generator", + short_name: "SigGen", + category: Category::Other, + params: &GEN_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// Test-tone and noise generator; replaces its input. +pub struct SignalGenerator { + p: Params, + sr: f32, + ch: usize, + osc: Osc, + level: Smoother, + freq: Smoother, +} + +impl Default for SignalGenerator { + fn default() -> Self { + Self::new() + } +} + +impl SignalGenerator { + pub fn new() -> Self { + let mut g = Self { + p: Params::new(&GEN_PARAMS), + sr: 48_000.0, + ch: 0, + osc: Osc::new(0xC0FFEE), + level: Smoother::new(0.1), + freq: Smoother::new(1000.0), + }; + g.update(); + g + } + + fn update(&mut self) { + self.level.set(fader_gain(self.p.v(2))); + self.freq.set(self.p.v(1)); + } +} + +impl Plugin for SignalGenerator { + param_plumbing!(GEN_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.ch = sane_channels(channels); + self.level.set_time(20.0, self.sr); + self.freq.set_time(20.0, self.sr); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + self.osc.reset(); + self.level.snap(); + self.freq.snap(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.ch.min(io.len()); + let frames = frames_in(io, frames, ch); + let wave = Waveform::from_index(self.p.choice(0)); + for n in 0..frames { + let g = self.level.next(); + let f = self.freq.next(); + let y = self.osc.next(wave, f / self.sr) * g; + for b in io.iter_mut().take(ch) { + if let Some(x) = b.get_mut(n) { + *x = y; + } + } + } + } +} + +// --------------------------------------------------------------------------------------------- + +static DITHER_PARAMS: [ParamInfo; 2] = [choice("bits", "Bit Depth", &["16", "20", "24"], 0), toggle("noise_shaping", "Noise Shaping", false)]; + +pub static DITHER_INFO: PluginInfo = PluginInfo { + id: "dither", + name: "Dither", + short_name: "Dither", + category: Category::Dither, + params: &DITHER_PARAMS, + is_instrument: false, + audiosuite: true, +}; + +/// TPDF dither and requantization with optional first-order error-feedback noise shaping. +/// Digital silence passes through as silence. +pub struct Dither { + p: Params, + rng: Rng, + err: Vec, +} + +impl Default for Dither { + fn default() -> Self { + Self::new() + } +} + +impl Dither { + pub fn new() -> Self { + Self { p: Params::new(&DITHER_PARAMS), rng: Rng::new(0xD17E), err: Vec::new() } + } + + fn update(&mut self) {} +} + +impl Plugin for Dither { + param_plumbing!(DITHER_INFO); + + fn prepare(&mut self, _sample_rate: f32, _max_block: usize, channels: usize) { + self.err = vec![0.0; sane_channels(channels)]; + } + + fn reset(&mut self) { + self.err.iter_mut().for_each(|e| *e = 0.0); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.err.len().min(io.len()); + let frames = frames_in(io, frames, ch); + let bits = [16.0f32, 20.0, 24.0].get(self.p.choice(0)).copied().unwrap_or(16.0); + let lsb = 2f32.powf(1.0 - bits); + let shape = self.p.on(1); + for (b, e) in io.iter_mut().zip(self.err.iter_mut()) { + for x in b.iter_mut().take(frames) { + if *x == 0.0 || !x.is_finite() { + *x = 0.0; + *e = 0.0; + continue; + } + let v = if shape { *x - *e } else { *x }; + let d = (self.rng.bipolar() + self.rng.bipolar()) * 0.5 * lsb; + let q = ((v + d) / lsb).round() * lsb; + *e = q - v; + *x = q.clamp(-1.0, 1.0 - lsb); + } + } + } +} diff --git a/crates/dsp/src/spectrum.rs b/crates/dsp/src/spectrum.rs new file mode 100644 index 00000000..2317f03e --- /dev/null +++ b/crates/dsp/src/spectrum.rs @@ -0,0 +1,52 @@ +//! FFT magnitude analysis for analyzers and EQ displays. + +use rustfft::{FftPlanner, num_complex::Complex}; + +/// Largest FFT size accepted (larger requests are clamped). +pub const MAX_FFT: usize = 1 << 18; + +/// Hann-windowed magnitude spectrum in dBFS, `fft_size / 2 + 1` bins (bin `k` is at +/// `k · sr / fft_size` Hz). A full-scale sine reads ≈ 0 dB. Input shorter than `fft_size` is +/// zero-padded; longer input uses the first `fft_size` samples. Floored at -144 dB. +pub fn magnitude_db(samples: &[f32], fft_size: usize) -> Vec { + let n = fft_size.min(MAX_FFT); + if n < 2 { + return Vec::new(); + } + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(n); + let mut buf: Vec> = (0..n) + .map(|i| { + let w = 0.5 - 0.5 * (std::f32::consts::TAU * i as f32 / n as f32).cos(); + let x = samples.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0); + Complex::new(x * w, 0.0) + }) + .collect(); + fft.process(&mut buf); + // Hann coherent gain is 0.5; a sine's energy splits across ±f. + let scale = 2.0 / (n as f32 * 0.5); + buf.iter().take(n / 2 + 1).map(|c| crate::gain_to_db(c.norm() * scale)).collect() +} + +/// Frequency in Hz of bin `k` for a given FFT size. +pub fn bin_hz(k: usize, fft_size: usize, sample_rate: f32) -> f32 { + if fft_size == 0 { 0.0 } else { k as f32 * sample_rate / fft_size as f32 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sine_peak_near_zero_db() { + let sr = 48_000.0; + let n = 4096; + let k = 100; + let f = bin_hz(k, n, sr); + let x: Vec = (0..n).map(|i| (std::f32::consts::TAU * f * i as f32 / sr).sin()).collect(); + let m = magnitude_db(&x, n); + assert_eq!(m.len(), n / 2 + 1); + assert!(m[k].abs() < 0.1, "{}", m[k]); + assert!(magnitude_db(&x, 0).is_empty()); + } +} diff --git a/crates/dsp/src/util.rs b/crates/dsp/src/util.rs new file mode 100644 index 00000000..d3b33afb --- /dev/null +++ b/crates/dsp/src/util.rs @@ -0,0 +1,311 @@ +//! Small realtime building blocks shared by the plugins. + +use std::f32::consts::TAU; + +/// Default sample rate used when a host passes something unusable. +pub(crate) const FALLBACK_SR: f32 = 48_000.0; + +/// Sanitizes a host sample rate into 1 kHz ..= 1.536 MHz. +pub(crate) fn sane_sr(sr: f32) -> f32 { + if sr.is_finite() && sr >= 1000.0 { sr.min(1_536_000.0) } else { FALLBACK_SR } +} + +/// Upper bound on channels any plugin will allocate state for. +pub(crate) const MAX_CHANNELS: usize = 64; + +pub(crate) fn sane_channels(ch: usize) -> usize { + ch.min(MAX_CHANNELS) +} + +/// Converts milliseconds to (fractional) samples, never negative or non-finite. +#[inline] +pub(crate) fn ms_to_samples(ms: f32, sr: f32) -> f32 { + let s = ms * 0.001 * sr; + if s.is_finite() { s.max(0.0) } else { 0.0 } +} + +/// One-pole coefficient for a time constant in milliseconds. +#[inline] +pub(crate) fn time_coef(ms: f32, sr: f32) -> f32 { + let n = ms_to_samples(ms, sr); + if n < 1.0e-3 { 0.0 } else { (-1.0 / n).exp() } +} + +/// Flushes denormals and non-finite values in feedback paths to zero. +#[inline] +pub(crate) fn flush(x: f32) -> f32 { + if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 } +} + +/// Number of frames a plugin may safely touch. +pub(crate) fn frames_in(io: &[Vec], frames: usize, channels: usize) -> usize { + io.iter().take(channels).map(Vec::len).fold(frames, usize::min) +} + +/// One-pole exponential parameter smoother (no zipper noise). +#[derive(Debug, Clone, Copy)] +pub(crate) struct Smoother { + cur: f32, + target: f32, + coef: f32, +} + +impl Smoother { + pub(crate) fn new(v: f32) -> Self { + Self { cur: v, target: v, coef: 0.0 } + } + + /// Sets the time constant (default ~20 ms is typical). + pub(crate) fn set_time(&mut self, ms: f32, sr: f32) { + self.coef = time_coef(ms, sr); + } + + pub(crate) fn set(&mut self, v: f32) { + if v.is_finite() { + self.target = v; + } + } + + pub(crate) fn snap(&mut self) { + self.cur = self.target; + } + + pub(crate) fn value(&self) -> f32 { + self.cur + } + + pub(crate) fn settling(&self) -> bool { + self.cur != self.target + } + + #[inline] + pub(crate) fn next(&mut self) -> f32 { + if self.cur != self.target { + self.cur = self.target + (self.cur - self.target) * self.coef; + if (self.cur - self.target).abs() <= 1.0e-6 * (1.0 + self.target.abs()) { + self.cur = self.target; + } + } + self.cur + } + + /// Advances `n` samples at once (used for block-rate coefficient updates). + pub(crate) fn advance(&mut self, n: usize) -> f32 { + if self.cur != self.target { + let k = self.coef.powi(n.min(i32::MAX as usize) as i32); + self.cur = self.target + (self.cur - self.target) * k; + if (self.cur - self.target).abs() <= 1.0e-6 * (1.0 + self.target.abs()) { + self.cur = self.target; + } + } + self.cur + } +} + +/// Power-of-two circular delay line with fractional reads. +#[derive(Debug, Clone, Default)] +pub(crate) struct DelayLine { + buf: Vec, + mask: usize, + pos: usize, +} + +impl DelayLine { + /// Allocates room for at least `max_delay` samples of delay. + pub(crate) fn allocate(&mut self, max_delay: usize) { + let len = max_delay.saturating_add(4).clamp(4, 1 << 26).next_power_of_two(); + self.buf = vec![0.0; len]; + self.mask = len - 1; + self.pos = 0; + } + + pub(crate) fn clear(&mut self) { + self.buf.iter_mut().for_each(|x| *x = 0.0); + } + + /// Largest delay (in samples) that `read` supports. + pub(crate) fn max_delay(&self) -> f32 { + self.buf.len().saturating_sub(3) as f32 + } + + #[inline] + pub(crate) fn push(&mut self, x: f32) { + if let Some(s) = self.buf.get_mut(self.pos) { + *s = x; + } + self.pos = (self.pos + 1) & self.mask; + } + + /// Reads the sample written `d` pushes ago (`d = 1` is the last written sample). + #[inline] + pub(crate) fn tap(&self, d: usize) -> f32 { + let idx = self.pos.wrapping_sub(d) & self.mask; + self.buf.get(idx).copied().unwrap_or(0.0) + } + + /// Linear-interpolated read `d` samples back (clamped to 1..=max_delay). + #[inline] + pub(crate) fn read(&self, d: f32) -> f32 { + let d = if d.is_finite() { d.clamp(1.0, self.max_delay().max(1.0)) } else { 1.0 }; + let i = d as usize; + let f = d - i as f32; + let a = self.tap(i); + let b = self.tap(i + 1); + a + (b - a) * f + } + + /// Cubic (Hermite) interpolated read, for modulated delays. + #[inline] + pub(crate) fn read_cubic(&self, d: f32) -> f32 { + let d = if d.is_finite() { d.clamp(2.0, (self.max_delay() - 1.0).max(2.0)) } else { 2.0 }; + let i = d as usize; + let f = d - i as f32; + let xm1 = self.tap(i - 1); + let x0 = self.tap(i); + let x1 = self.tap(i + 1); + let x2 = self.tap(i + 2); + let c1 = 0.5 * (x1 - xm1); + let c2 = xm1 - 2.5 * x0 + 2.0 * x1 - 0.5 * x2; + let c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1); + ((c3 * f + c2) * f + c1) * f + x0 + } +} + +/// Xorshift32 noise source (deterministic, allocation free). +#[derive(Debug, Clone, Copy)] +pub(crate) struct Rng(u32); + +impl Rng { + pub(crate) fn new(seed: u32) -> Self { + Self(if seed == 0 { 0x9E37_79B9 } else { seed }) + } + + #[inline] + pub(crate) fn next_u32(&mut self) -> u32 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.0 = x; + x + } + + /// Uniform white noise in -1..1. + #[inline] + pub(crate) fn bipolar(&mut self) -> f32 { + (self.next_u32() >> 8) as f32 * (2.0 / 16_777_216.0) - 1.0 + } +} + +/// Pink noise via a sum of first-order filtered white noise (Kellet-style economy filter). +#[derive(Debug, Clone, Copy)] +pub(crate) struct PinkNoise { + b: [f32; 7], +} + +impl PinkNoise { + pub(crate) fn new() -> Self { + Self { b: [0.0; 7] } + } + + #[inline] + pub(crate) fn next(&mut self, white: f32) -> f32 { + let b = &mut self.b; + b[0] = 0.99886 * b[0] + white * 0.0555179; + b[1] = 0.99332 * b[1] + white * 0.0750759; + b[2] = 0.96900 * b[2] + white * 0.153_852; + b[3] = 0.86650 * b[3] + white * 0.3104856; + b[4] = 0.55000 * b[4] + white * 0.5329522; + b[5] = -0.7616 * b[5] - white * 0.016_898; + let out = b[0] + b[1] + b[2] + b[3] + b[4] + b[5] + b[6] + white * 0.5362; + b[6] = white * 0.115926; + out * 0.11 + } +} + +/// Sine LFO with phase in 0..1. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Lfo { + phase: f32, +} + +impl Lfo { + pub(crate) fn new() -> Self { + Self { phase: 0.0 } + } + + pub(crate) fn reset(&mut self) { + self.phase = 0.0; + } + + /// Advances by `inc` cycles and returns sin at `offset` cycles ahead (for stereo spread). + #[inline] + pub(crate) fn tick(&mut self, inc: f32) -> f32 { + let inc = if inc.is_finite() { inc.clamp(0.0, 0.5) } else { 0.0 }; + self.phase += inc; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + self.phase + } + + #[inline] + pub(crate) fn sin_at(phase: f32) -> f32 { + (phase * TAU).sin() + } +} + +/// DC blocking one-pole high-pass. +#[derive(Debug, Clone, Copy)] +pub(crate) struct DcBlocker { + r: f32, + x1: f32, + y1: f32, +} + +impl DcBlocker { + pub(crate) fn new(cutoff_hz: f32, sr: f32) -> Self { + let mut d = Self { r: 0.995, x1: 0.0, y1: 0.0 }; + d.set_cutoff(cutoff_hz, sr); + d + } + + pub(crate) fn set_cutoff(&mut self, cutoff_hz: f32, sr: f32) { + let fc = if cutoff_hz.is_finite() { cutoff_hz.clamp(0.1, 1000.0) } else { 5.0 }; + self.r = (-TAU * fc / sane_sr(sr)).exp(); + } + + pub(crate) fn reset(&mut self) { + self.x1 = 0.0; + self.y1 = 0.0; + } + + #[inline] + pub(crate) fn process(&mut self, x: f32) -> f32 { + let y = x - self.x1 + self.r * self.y1; + self.x1 = x; + self.y1 = flush(y); + self.y1 + } +} + +/// Sorts pending note events by offset in place (insertion sort; tiny lists, no allocation). +pub(crate) fn sort_events(ev: &mut [T], key: impl Fn(&T) -> usize) { + for i in 1..ev.len() { + let mut j = i; + while j > 0 { + let (Some(a), Some(b)) = (ev.get(j - 1), ev.get(j)) else { break }; + if key(a) <= key(b) { + break; + } + ev.swap(j - 1, j); + j -= 1; + } + } +} + +/// Converts a MIDI note number to Hz (A4 = 440). +#[inline] +pub(crate) fn note_hz(note: f32) -> f32 { + 440.0 * 2f32.powf((note - 69.0) / 12.0) +} diff --git a/crates/dsp/tests/fuzz.rs b/crates/dsp/tests/fuzz.rs new file mode 100644 index 00000000..0a1564a5 --- /dev/null +++ b/crates/dsp/tests/fuzz.rs @@ -0,0 +1,56 @@ +use proptest::prelude::*; +use soundcraft_dsp::{create, offline, plugins}; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(48))] + + /// Any parameter values and any finite input: no panic, finite output. + #[test] + fn plugins_survive_random_params( + which in 0usize..64, + values in proptest::collection::vec(any::(), 32), + input in proptest::collection::vec(-4.0f32..4.0, 1..700), + block in 1usize..300, + ) { + let info = plugins()[which % plugins().len()]; + let mut p = create(info.id).unwrap(); + p.prepare(44_100.0, block, 2); + for (param, v) in info.params.iter().zip(values.iter().cycle()) { + p.set_param(param.id, *v); + } + p.note_on(0, 60, 100); + let mut io = [input.clone(), input.clone()]; + let len = input.len(); + let mut pos = 0; + while pos < len { + let n = block.min(len - pos); + let mut buf = vec![io[0][pos..pos + n].to_vec(), io[1][pos..pos + n].to_vec()]; + p.process(&mut buf, n); + io[0][pos..pos + n].copy_from_slice(&buf[0]); + io[1][pos..pos + n].copy_from_slice(&buf[1]); + pos += n; + } + prop_assert!(io.iter().flatten().all(|v| v.is_finite()), "{} produced non-finite output", info.id); + } + + /// Offline helpers accept any arguments without panicking. + #[test] + fn offline_survives_random_args( + data in proptest::collection::vec(any::(), 0..400), + a in any::(), + n in 0usize..1000, + r in any::(), + ) { + let ch = vec![data.clone(), data]; + let _ = offline::non_silent_ranges(&ch, a, n, n, n); + let _ = offline::detect_transients(&ch, a, a); + let _ = offline::time_stretch(&ch, r, a); + let _ = offline::pitch_shift(&ch, a, a); + let _ = offline::resample(&ch, n as u32, (n as u32).wrapping_mul(3) % 200_000); + let mut m = ch.clone(); + offline::normalize(&mut m, a, n % 2 == 0); + offline::fade(&mut m, n, n / 2, offline::FadeShape::SCurve); + offline::gain(&mut m, a); + let _ = soundcraft_dsp::spectrum::magnitude_db(&ch[0], n); + } +} diff --git a/crates/dsp/tests/offline_meters.rs b/crates/dsp/tests/offline_meters.rs new file mode 100644 index 00000000..f83f241c --- /dev/null +++ b/crates/dsp/tests/offline_meters.rs @@ -0,0 +1,252 @@ +use std::f32::consts::TAU; + +use soundcraft_dsp::meter::{LoudnessMeter, PeakMeter, RmsMeter}; +use soundcraft_dsp::offline::{self, FadeShape, Waveform}; +use soundcraft_dsp::spectrum::{bin_hz, magnitude_db}; +use soundcraft_dsp::{create, db_to_gain, gain_to_db, pan}; + +fn sine(freq: f32, amp: f32, sr: f32, len: usize) -> Vec { + (0..len).map(|i| amp * (TAU * freq * i as f32 / sr).sin()).collect() +} + +fn peak(x: &[f32]) -> f32 { + x.iter().fold(0.0f32, |m, v| m.max(v.abs())) +} + +/// Dominant frequency by FFT with parabolic interpolation. +fn dominant_hz(x: &[f32], sr: f32) -> f32 { + let n = 32_768.min(x.len().next_power_of_two() / 2); + let spec = magnitude_db(&x[x.len() / 2 - n / 2..], n); + let k = spec.iter().enumerate().skip(1).max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0; + let (a, b, c) = (spec[k - 1], spec[k], spec[k + 1]); + let d = 0.5 * (a - c) / (a - 2.0 * b + c); + bin_hz(k, n, sr) + d * sr / n as f32 +} + +#[test] +fn db_helpers() { + assert_eq!(db_to_gain(-200.0), 0.0); + assert_eq!(db_to_gain(f32::NAN), 0.0); + assert!((db_to_gain(-6.0206) - 0.5).abs() < 1e-4); + assert_eq!(gain_to_db(0.0), -144.0); + assert_eq!(gain_to_db(f32::NAN), -144.0); + assert!((gain_to_db(0.5) + 6.0206).abs() < 1e-3); + assert!((gain_to_db(-1.0)).abs() < 1e-6); +} + +#[test] +fn pan_law_hard_right() { + let (l, r) = pan::gains(1.0, pan::PanLaw::Minus3); + assert!(l.abs() < 1e-6 && (r - 1.0).abs() < 1e-6); + let (l, r) = pan::gains(f32::NAN, pan::PanLaw::Minus6); + assert!((l - r).abs() < 1e-6); +} + +#[test] +fn loudness_997hz_minus20_stereo_reads_minus20() { + let sr = 48_000.0; + let x = sine(997.0, db_to_gain(-20.0), sr, 48_000 * 10); + let mut m = LoudnessMeter::new(sr, 2); + m.process(&[x.clone(), x], 48_000 * 10); + assert!((m.integrated_lufs() + 20.0).abs() < 0.1, "integrated {}", m.integrated_lufs()); + assert!((m.momentary_lufs() + 20.0).abs() < 0.1, "momentary {}", m.momentary_lufs()); + assert!((m.short_term_lufs() + 20.0).abs() < 0.1, "short {}", m.short_term_lufs()); + assert!((m.true_peak_db() + 20.0).abs() < 0.2, "tp {}", m.true_peak_db()); +} + +#[test] +fn loudness_full_scale_mono_and_44k() { + for sr in [44_100.0f32, 48_000.0, 96_000.0] { + let len = (sr * 5.0) as usize; + let x = sine(997.0, 1.0, sr, len); + let mut m = LoudnessMeter::new(sr, 1); + m.process(&[x], len); + assert!((m.integrated_lufs() + 3.01).abs() < 0.1, "{sr}: {}", m.integrated_lufs()); + } +} + +#[test] +fn loudness_gating_ignores_silence() { + let sr = 48_000.0; + let mut x = sine(997.0, db_to_gain(-20.0), sr, 48_000 * 5); + x.extend(std::iter::repeat_n(0.0, 48_000 * 20)); + let mut m = LoudnessMeter::new(sr, 2); + m.process(&[x.clone(), x], 48_000 * 25); + assert!((m.integrated_lufs() + 20.0).abs() < 0.2, "{}", m.integrated_lufs()); + assert!(m.momentary_lufs() < -100.0); + m.reset(); + assert_eq!(m.integrated_lufs(), -144.0); +} + +#[test] +fn true_peak_catches_intersample_overs() { + // A sine at fs/4 phased 45° has samples at ±0.707·A but true peak A. + let sr = 48_000.0; + let x: Vec = (0..48_000).map(|i| (TAU * 0.25 * (i % 4) as f32 + TAU / 8.0).sin()).collect(); + assert!(peak(&x) < 0.71); + let mut m = LoudnessMeter::new(sr, 1); + m.process(&[x], 48_000); + assert!(m.true_peak_db() > -0.5, "{}", m.true_peak_db()); +} + +#[test] +fn peak_meter_ballistics() { + let mut m = PeakMeter::new(48_000.0); + m.process(&[0.5; 480]); + assert!((m.level_db() + 6.02).abs() < 0.01); + m.process(&vec![0.0; 48_000]); + assert!((m.level_db() - (-6.02 - 20.0)).abs() < 0.1, "{}", m.level_db()); + assert!((m.peak_hold_db() + 6.02).abs() < 0.01); + assert!(!m.clipped()); + m.process(&[1.0]); + assert!(m.clipped()); + m.reset_clip(); + assert!(!m.clipped()); + let mut r = RmsMeter::new(48_000.0); + r.process(&sine(1000.0, 1.0, 48_000.0, 48_000)); + assert!((r.rms_db() + 3.01).abs() < 0.2, "{}", r.rms_db()); +} + +#[test] +fn resample_preserves_frequency_and_length() { + let x = sine(1000.0, 0.5, 48_000.0, 48_000); + let out = offline::resample(std::slice::from_ref(&x), 48_000, 44_100); + assert_eq!(out[0].len(), 44_100); + let f = dominant_hz(&out[0], 44_100.0); + assert!((f - 1000.0).abs() < 1.0, "{f}"); + assert!((peak(&out[0][1000..43_000]) - 0.5).abs() < 0.01); + let up = offline::resample(&out, 44_100, 96_000); + assert_eq!(up[0].len(), 96_000); + assert!((dominant_hz(&up[0], 96_000.0) - 1000.0).abs() < 1.0); + // Content above the new Nyquist is removed. + let hi = sine(23_000.0, 0.5, 48_000.0, 48_000); + let down = offline::resample(&[hi], 48_000, 22_050); + assert!(peak(&down[0][1000..20_000]) < 0.01, "alias {}", peak(&down[0][1000..20_000])); + assert_eq!(offline::resample(&[vec![1.0; 10]], 0, 48_000)[0].len(), 10); +} + +#[test] +fn time_stretch_length_and_pitch() { + let sr = 48_000.0; + let x = sine(440.0, 0.5, sr, 48_000); + for ratio in [0.5, 0.8, 1.25, 2.0] { + let out = offline::time_stretch(&[x.clone(), x.clone()], ratio, sr); + assert_eq!(out.len(), 2); + assert_eq!(out[0].len(), (48_000.0 * ratio).round() as usize); + let f = dominant_hz(&out[0], sr); + assert!((f - 440.0).abs() < 3.0, "ratio {ratio}: {f}"); + } + assert!(offline::time_stretch(&[vec![]], 2.0, sr)[0].is_empty()); + let _ = offline::time_stretch(std::slice::from_ref(&x), f64::NAN, f32::NAN); +} + +#[test] +fn pitch_shift_preserves_length_and_shifts() { + let sr = 48_000.0; + let x = sine(440.0, 0.5, sr, 48_000); + let out = offline::pitch_shift(std::slice::from_ref(&x), 12.0, sr); + assert_eq!(out[0].len(), x.len()); + let f = dominant_hz(&out[0], sr); + assert!((f - 880.0).abs() < 6.0, "{f}"); + let down = offline::pitch_shift(std::slice::from_ref(&x), -7.0, sr); + assert_eq!(down[0].len(), x.len()); + let f = dominant_hz(&down[0], sr); + let want = 440.0 * 2f32.powf(-7.0 / 12.0); + assert!((f - want).abs() < 4.0, "{f} vs {want}"); +} + +#[test] +fn normalize_hits_target_peak() { + let mut ch = vec![sine(100.0, 0.2, 48_000.0, 4800), sine(100.0, 0.05, 48_000.0, 4800)]; + offline::normalize(&mut ch, -1.0, false); + assert!((gain_to_db(peak(&ch[0])) + 1.0).abs() < 0.01); + assert!((gain_to_db(peak(&ch[1])) + 13.04).abs() < 0.05); + offline::normalize(&mut ch, -3.0, true); + assert!((gain_to_db(peak(&ch[1])) + 3.0).abs() < 0.01); + let mut silent = vec![vec![0.0; 10]]; + offline::normalize(&mut silent, 0.0, true); + assert!(silent[0].iter().all(|&v| v == 0.0)); +} + +#[test] +fn simple_offline_ops() { + let mut ch = vec![vec![1.0, 2.0, 3.0]]; + offline::reverse(&mut ch); + assert_eq!(ch[0], vec![3.0, 2.0, 1.0]); + offline::invert(&mut ch); + assert_eq!(ch[0], vec![-3.0, -2.0, -1.0]); + offline::remove_dc(&mut ch); + assert!(ch[0].iter().sum::().abs() < 1e-5); + offline::gain(&mut ch, f32::NAN); + offline::fade(&mut ch, 2, 2, FadeShape::EqualPower); + assert_eq!(ch[0][0], 0.0); +} + +#[test] +fn non_silent_ranges_finds_bursts() { + let sr = 48_000.0; + let mut x = vec![0.0f32; 48_000]; + for (i, v) in sine(500.0, 0.5, sr, 4800).into_iter().enumerate() { + x[10_000 + i] = v; + x[30_000 + i] = v; + } + let r = offline::non_silent_ranges(std::slice::from_ref(&x), -40.0, 480, 100, 200); + assert_eq!(r.len(), 2, "{r:?}"); + assert!((r[0].0 as i64 - 9_900).abs() <= 5 && (r[0].1 as i64 - 15_000).abs() <= 5, "{r:?}"); + assert!((r[1].0 as i64 - 29_900).abs() <= 5, "{r:?}"); + // A huge minimum gap merges them. + let r = offline::non_silent_ranges(&[x], -40.0, 30_000, 0, 0); + assert_eq!(r.len(), 1); + assert!(offline::non_silent_ranges(&[vec![0.0; 100]], -40.0, 10, 5, 5).is_empty()); +} + +#[test] +fn transients_detected_near_onsets() { + let sr = 48_000.0; + let mut x = vec![0.0f32; 48_000 * 2]; + let onsets = [9_600usize, 33_600, 60_000, 81_000]; + for &o in &onsets { + for i in 0..6000 { + x[o + i] += 0.8 * (-(i as f32) / 1500.0).exp() * (TAU * 180.0 * i as f32 / sr).sin(); + } + } + let t = offline::detect_transients(&[x], sr, 0.5); + assert_eq!(t.len(), onsets.len(), "{t:?}"); + for (a, b) in t.iter().zip(onsets) { + assert!((*a as i64 - b as i64).abs() < 480, "{a} vs {b}"); + } + assert!(offline::detect_transients(&[vec![0.0; 48_000]], sr, 1.0).is_empty()); +} + +#[test] +fn signal_generator_offline() { + for w in Waveform::ALL { + let out = offline::signal_generator(w, 1000.0, -6.0, 48_000.0, 4800, 2); + assert_eq!(out.len(), 2); + assert_eq!(out[0].len(), 4800); + assert!(peak(&out[0]) <= db_to_gain(-6.0) * 1.1 + 1e-3, "{w:?} {}", peak(&out[0])); + assert!(peak(&out[0]) > 0.05, "{w:?}"); + } +} + +#[test] +fn apply_plugin_compensates_latency() { + let sr = 48_000.0; + let mut p = create("pitch_shifter").unwrap(); + let mut x = vec![vec![0.0f32; 10_000]]; + x[0][2000] = 1.0; + offline::apply_plugin(&mut x, p.as_mut(), sr); + let pos = x[0].iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).unwrap().0; + assert_eq!(pos, 2000); + + let mut r = create("room_reverb").unwrap(); + let out = offline::apply_plugin_with_tail(&[vec![1.0; 100]], r.as_mut(), sr); + assert!(out[0].len() > 100 + 48_000); +} + +#[test] +fn spectrum_edge_cases() { + assert!(magnitude_db(&[], 1).is_empty()); + let m = magnitude_db(&[f32::NAN; 64], 64); + assert!(m.iter().all(|v| v.is_finite())); +} diff --git a/crates/dsp/tests/plugins.rs b/crates/dsp/tests/plugins.rs new file mode 100644 index 00000000..5d71f6f3 --- /dev/null +++ b/crates/dsp/tests/plugins.rs @@ -0,0 +1,443 @@ +use std::collections::HashSet; +use std::f32::consts::TAU; + +use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, plugin_info, plugins}; + +const SR: f32 = 48_000.0; +const BLOCK: usize = 512; + +fn run(p: &mut dyn Plugin, input: &[Vec]) -> Vec> { + let len = input[0].len(); + let ch = input.len(); + let mut out = vec![Vec::with_capacity(len); ch]; + let mut pos = 0; + let mut buf = vec![vec![0.0f32; BLOCK]; ch]; + while pos < len { + let n = BLOCK.min(len - pos); + for c in 0..ch { + buf[c][..n].copy_from_slice(&input[c][pos..pos + n]); + } + p.process(&mut buf, n); + for c in 0..ch { + out[c].extend_from_slice(&buf[c][..n]); + } + pos += n; + } + out +} + +fn sine(freq: f32, amp: f32, len: usize) -> Vec { + (0..len).map(|i| amp * (TAU * freq * i as f32 / SR).sin()).collect() +} + +fn peak(x: &[f32]) -> f32 { + x.iter().fold(0.0f32, |m, v| m.max(v.abs())) +} + +fn rms(x: &[f32]) -> f32 { + (x.iter().map(|v| v * v).sum::() / x.len().max(1) as f32).sqrt() +} + +fn prepared(id: &str) -> Box { + let mut p = create(id).unwrap(); + p.prepare(SR, BLOCK, 2); + p +} + +fn is_source(id: &str) -> bool { + plugin_info(id).unwrap().is_instrument || id == "signal_generator" +} + +#[test] +fn registry_ids_unique_and_creatable() { + let mut ids = HashSet::new(); + for info in plugins() { + assert!(ids.insert(info.id), "duplicate id {}", info.id); + assert!(info.short_name.chars().count() <= 8, "{} short name too long", info.id); + let p = create(info.id).unwrap_or_else(|| panic!("cannot create {}", info.id)); + assert_eq!(p.info().id, info.id); + assert_eq!(plugin_info(info.id).unwrap().id, info.id); + } + assert!(create("nope").is_none()); + assert!(plugins().len() >= 25); +} + +#[test] +fn params_well_formed() { + for info in plugins() { + let mut ids = HashSet::new(); + for p in info.params { + assert!(ids.insert(p.id), "{}: duplicate param {}", info.id, p.id); + assert!(p.min <= p.default && p.default <= p.max, "{}.{} default out of range", info.id, p.id); + assert!(p.min.is_finite() && p.max.is_finite()); + if p.unit == Unit::Choice { + assert_eq!(p.choices.len() as f32, p.max + 1.0, "{}.{}", info.id, p.id); + } + let n = p.to_normalized(p.default); + assert!((p.from_normalized(n) - p.default).abs() <= 1e-3 * (1.0 + p.default.abs()), "{}.{}", info.id, p.id); + } + let mut plug = create(info.id).unwrap(); + for p in info.params { + assert_eq!(plug.param(p.id), Some(p.default), "{}.{}", info.id, p.id); + assert!(plug.set_param(p.id, p.max + 1000.0)); + assert_eq!(plug.param(p.id), Some(p.max)); + } + assert!(!plug.set_param("definitely_not_a_param", 1.0)); + assert_eq!(plug.param("definitely_not_a_param"), None); + } +} + +#[test] +fn silence_in_silence_out() { + for info in plugins() { + if is_source(info.id) { + continue; + } + let mut p = prepared(info.id); + let out = run(p.as_mut(), &vec![vec![0.0; 48_000]; 2]); + for c in &out { + assert!(peak(c) < 1e-6, "{} made noise from silence: {}", info.id, peak(c)); + } + } +} + +#[test] +fn impulse_response_finite_and_bounded() { + for info in plugins() { + let mut p = prepared(info.id); + let mut x = vec![vec![0.0; 96_000]; 2]; + x[0][100] = 1.0; + x[1][100] = 1.0; + p.note_on(0, 36, 100); + let out = run(p.as_mut(), &x); + for c in &out { + assert!(c.iter().all(|v| v.is_finite()), "{} produced non-finite output", info.id); + assert!(peak(c) < 8.0, "{} impulse peak {}", info.id, peak(c)); + } + } +} + +#[test] +fn hostile_params_never_break_processing() { + let hostile = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1e30, 1e30, 0.0, -0.0]; + for info in plugins() { + for &v in &hostile { + let mut p = prepared(info.id); + for param in info.params { + assert!(p.set_param(param.id, v)); + let got = p.param(param.id).unwrap(); + assert!(got.is_finite() && got >= param.min && got <= param.max, "{}.{} = {got}", info.id, param.id); + } + p.note_on(3, 60, 127); + let x = vec![sine(440.0, 0.5, 9600), sine(660.0, 0.5, 9600)]; + let out = run(p.as_mut(), &x); + assert!(out.iter().flatten().all(|s| s.is_finite()), "{} non-finite with param value {v}", info.id); + let _ = p.latency(); + let _ = p.tail_samples(); + assert!(p.gain_reduction_db().is_finite()); + } + } +} + +#[test] +fn odd_host_calls_do_not_panic() { + for info in plugins() { + let mut p = create(info.id).unwrap(); + // Unprepared-ish, weird rates, block bigger than prepared, mismatched channels. + for (sr, block, ch) in [(0.0, 0, 0), (f32::NAN, 16, 1), (-44_100.0, 64, 2), (1e9, 64, 8), (44_100.0, 32, 2)] { + p.prepare(sr, block, ch); + let mut io = vec![vec![0.25f32; 300]; 3]; + p.process(&mut io, 4096); + p.process(&mut io, 0); + p.process(&mut [], 128); + let mut short = vec![vec![0.1f32; 10], vec![0.1f32; 3]]; + p.process(&mut short, 100); + p.note_on(10_000, 200, 255); + p.note_off(0, 200); + p.all_notes_off(); + p.process(&mut io, 300); + p.reset(); + } + } +} + +#[test] +fn param_changes_are_smooth() { + // Jumping gain by 24 dB mid-stream must not produce a step discontinuity. + let mut p = prepared("gain"); + let x = vec![vec![0.5f32; 4096]; 1]; + let _ = run(p.as_mut(), &x); + p.set_param("gain", 24.0); + let out = run(p.as_mut(), &x); + let max_step = out[0].windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max); + assert!(max_step < 0.05, "zipper step {max_step}"); + let out = run(p.as_mut(), &x); + assert!((out[0][4095] - 0.5 * db_to_gain(24.0)).abs() < 0.01); +} + +#[test] +fn eq_magnitude_at_center_matches_gain() { + let sr = SR; + let resp = soundcraft_dsp::eq7_response(&[("mid_freq", 1000.0), ("mid_gain", 9.0), ("mid_q", 2.0)], &[1000.0, 30.0, 15_000.0], sr); + assert!((resp[0] - 9.0).abs() < 0.05, "{resp:?}"); + assert!(resp[1].abs() < 0.2 && resp[2].abs() < 0.2, "{resp:?}"); + let r1 = soundcraft_dsp::eq1_response(&[("freq", 2500.0), ("gain", -12.0)], &[2500.0], sr); + assert!((r1[0] + 12.0).abs() < 0.05); + + // And the real-time EQ agrees with the curve. + let mut p = prepared("eq_7band"); + p.set_param("mid_freq", 1000.0); + p.set_param("mid_gain", 9.0); + p.set_param("mid_q", 2.0); + p.reset(); + let out = run(p.as_mut(), &[sine(1000.0, 0.1, 48_000)]); + let g = gain_to_db(rms(&out[0][24_000..]) / rms(&sine(1000.0, 0.1, 48_000)[24_000..])); + assert!((g - 9.0).abs() < 0.2, "measured {g}"); + + // HPF attenuates lows. + let r = soundcraft_dsp::eq7_response(&[("hpf_on", 1.0), ("hpf_freq", 200.0), ("hpf_slope", 3.0)], &[50.0, 200.0, 2000.0], sr); + assert!(r[0] < -40.0 && (r[1] + 3.0).abs() < 0.2 && r[2].abs() < 0.1, "{r:?}"); +} + +#[test] +fn compressor_steady_state_gain_reduction() { + let mut p = prepared("compressor"); + p.set_param("threshold", -20.0); + p.set_param("ratio", 4.0); + p.set_param("knee", 0.0); + p.set_param("attack", 1.0); + p.set_param("release", 50.0); + // Constant level at -10 dBFS: 10 dB over → 7.5 dB of reduction. + let level = db_to_gain(-10.0); + let out = run(p.as_mut(), &vec![vec![level; 48_000]; 2]); + let got = gain_to_db(out[0][47_999]) - -10.0; + assert!((got + 7.5).abs() < 0.1, "gain change {got}"); + assert!((p.gain_reduction_db() - 7.5).abs() < 0.1); + + // A sine settles near the same reduction (peak detection). + p.reset(); + let x = sine(1000.0, level, 48_000); + let out = run(p.as_mut(), &[x.clone(), x]); + let got = gain_to_db(peak(&out[0][40_000..])) + 10.0; + assert!((got + 7.5).abs() < 1.0, "sine gain change {got}"); + + // Limiter mode with lookahead reports latency. + p.set_param("limiter", 1.0); + p.set_param("lookahead", 5.0); + assert_eq!(p.latency(), 240); +} + +#[test] +fn gate_closes_below_threshold() { + let mut p = prepared("expander_gate"); + p.set_param("threshold", -40.0); + p.set_param("range", 80.0); + p.set_param("hold", 10.0); + p.set_param("release", 20.0); + let quiet = sine(300.0, db_to_gain(-60.0), 48_000); + let out = run(p.as_mut(), std::slice::from_ref(&quiet)); + let atten = gain_to_db(peak(&out[0][24_000..]) / peak(&quiet[24_000..])); + assert!(atten < -70.0, "gate attenuation only {atten}"); + let loud = sine(300.0, db_to_gain(-10.0), 48_000); + let out = run(p.as_mut(), std::slice::from_ref(&loud)); + let g = gain_to_db(peak(&out[0][24_000..]) / peak(&loud[24_000..])); + assert!(g.abs() < 0.5, "open gate changed level {g}"); +} + +#[test] +fn maximizer_respects_ceiling_and_reports_latency() { + let mut p = prepared("maximizer"); + p.set_param("threshold", -12.0); + p.set_param("ceiling", -1.0); + let x = sine(220.0, 0.9, 48_000); + let out = run(p.as_mut(), &[x.clone(), x]); + assert!(peak(&out[0]) <= db_to_gain(-1.0) + 1e-5, "{}", peak(&out[0])); + assert!(p.latency() > 0 && p.latency() < 200); + assert!(p.gain_reduction_db() > 5.0); +} + +#[test] +fn de_esser_reduces_sibilance_only() { + let mut p = prepared("de_esser"); + p.set_param("threshold", -40.0); + p.set_param("range", 20.0); + let hi = sine(8000.0, 0.5, 48_000); + let out = run(p.as_mut(), std::slice::from_ref(&hi)); + assert!(gain_to_db(rms(&out[0][24_000..]) / rms(&hi[24_000..])) < -6.0); + p.reset(); + let lo = sine(200.0, 0.005, 48_000); + let out = run(p.as_mut(), std::slice::from_ref(&lo)); + assert!(gain_to_db(rms(&out[0][24_000..]) / rms(&lo[24_000..])).abs() < 0.5); +} + +#[test] +fn latency_reporting_aligns_pitch_shifter() { + let mut p = prepared("pitch_shifter"); + let lat = p.latency(); + assert!(lat > 0); + let mut x = vec![0.0f32; 8000]; + x[1000] = 1.0; + let out = run(p.as_mut(), &[x]); + let pos = out[0].iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).unwrap().0; + assert_eq!(pos, 1000 + lat); +} + +#[test] +fn pitch_shifter_moves_pitch() { + let mut p = prepared("pitch_shifter"); + p.set_param("semitones", 12.0); + p.reset(); + let out = run(p.as_mut(), &[sine(440.0, 0.5, 48_000)]); + let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][16_384..], 16_384); + let bin = spec.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0; + let hz = soundcraft_dsp::spectrum::bin_hz(bin, 16_384, SR); + assert!((hz - 880.0).abs() < 15.0, "peak at {hz}"); +} + +#[test] +fn reverb_has_tail_and_decays() { + for id in ["room_reverb", "plate_reverb"] { + let mut p = prepared(id); + p.set_param("mix", 100.0); + p.set_param("decay", 1.0); + let mut x = vec![vec![0.0; 48_000 * 4]; 2]; + x[0][0] = 1.0; + x[1][0] = 1.0; + let out = run(p.as_mut(), &x); + let early = rms(&out[0][2400..24_000]); + let late = rms(&out[0][48_000 * 3..]); + assert!(early > 1e-3, "{id} no reverb"); + assert!(late < early * 1e-2, "{id} not decaying {early} {late}"); + assert!(p.tail_samples() > 48_000); + // Stereo decorrelation. + assert!(out[0][2400..4800] != out[1][2400..4800]); + } +} + +#[test] +fn delay_repeats_at_time() { + let mut p = prepared("mod_delay"); + p.set_param("time", 100.0); + p.set_param("mix", 100.0); + p.set_param("feedback", 0.0); + p.set_param("lpf", 20_000.0); + p.reset(); + let mut x = vec![0.0f32; 20_000]; + x[0] = 1.0; + let out = run(p.as_mut(), &[x]); + let pos = out[0].iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).unwrap().0; + assert!((pos as i64 - 4800).abs() <= 2, "echo at {pos}"); +} + +#[test] +fn trim_and_invert() { + let mut p = prepared("invert"); + let out = run(p.as_mut(), &[vec![0.5; 16]]); + assert!(out[0].iter().all(|&v| v == -0.5)); + let mut t = prepared("trim"); + t.set_param("gain", -96.0); + t.reset(); + let out = run(t.as_mut(), &[vec![0.5; 16]]); + assert!(out[0].iter().all(|&v| v == 0.0)); + t.set_param("gain", 0.0); + t.set_param("invert", 1.0); + t.reset(); + let out = run(t.as_mut(), &[vec![0.5; 16]]); + assert!(out[0].iter().all(|&v| (v + 0.5).abs() < 1e-6)); +} + +#[test] +fn time_shift_delays() { + let mut p = prepared("time_shift"); + p.set_param("delay_samples", 123.0); + p.reset(); + let mut x = vec![0.0f32; 1000]; + x[10] = 1.0; + let out = run(p.as_mut(), &[x]); + assert_eq!(out[0][133], 1.0); +} + +#[test] +fn dc_removal_removes_dc() { + let mut p = prepared("dc_offset_removal"); + let out = run(p.as_mut(), &[vec![0.3; 96_000]]); + assert!(out[0][95_999].abs() < 1e-3); +} + +#[test] +fn signal_generator_level() { + let mut p = prepared("signal_generator"); + p.set_param("level", -6.0); + p.reset(); + let out = run(p.as_mut(), &[vec![0.0; 48_000]]); + assert!((gain_to_db(peak(&out[0])) + 6.0).abs() < 0.05); +} + +#[test] +fn synth_note_on_sounds_and_note_off_decays() { + let mut p = prepared("subtractive_synth"); + p.note_on(0, 60, 100); + let out = run(p.as_mut(), &vec![vec![0.0; 24_000]; 2]); + assert!(rms(&out[0][4800..]) > 0.01, "synth silent"); + assert_eq!(out[0], out[1]); + p.note_off(0, 60); + let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]); + assert!(peak(&out[0][72_000..]) < 1e-6, "synth did not decay"); + // Polyphony: a chord is louder than one note and stays finite. + for n in [60, 64, 67, 71, 74, 77, 81, 84, 60, 62, 65, 69, 72, 76, 79, 83, 86, 88] { + p.note_on(0, n, 127); + } + let out = run(p.as_mut(), &vec![vec![0.0; 9600]; 2]); + assert!(out[0].iter().all(|v| v.is_finite()) && peak(&out[0]) > 0.05); + p.all_notes_off(); + let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]); + assert!(peak(&out[0][72_000..]) < 1e-6); +} + +#[test] +fn synth_event_offsets_are_sample_accurate() { + let mut p = prepared("subtractive_synth"); + p.set_param("amp_attack", 0.5); + p.note_on(300, 69, 127); + let out = run(p.as_mut(), &[vec![0.0; 512]]); + assert!(out[0][..300].iter().all(|&v| v == 0.0)); + assert!(out[0][300..].iter().any(|&v| v != 0.0)); +} + +#[test] +fn drum_kit_notes_sound_and_end() { + for note in [36, 38, 39, 42, 46, 45, 47, 50, 49, 51] { + let mut p = prepared("drum_synth"); + p.note_on(0, note, 120); + let out = run(p.as_mut(), &[vec![0.0; 48_000 * 7]]); + assert!(peak(&out[0][..4800]) > 0.01, "drum note {note} silent"); + assert!(peak(&out[0][48_000 * 6..]) < 1e-6, "drum note {note} rings forever"); + } + let mut p = prepared("drum_synth"); + p.note_on(0, 99, 120); + let out = run(p.as_mut(), &[vec![0.0; 4800]]); + assert!(peak(&out[0]) == 0.0, "unmapped note should be silent"); +} + +#[test] +fn saturator_adds_harmonics_and_stays_bounded() { + let mut p = prepared("saturator"); + p.set_param("drive", 30.0); + let out = run(p.as_mut(), &[sine(1000.0, 1.0, 48_000)]); + assert!(peak(&out[0]) <= 1.01); + let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192); + let third = (3000.0 / (SR / 8192.0)) as usize; + assert!(spec[third - 1..=third + 1].iter().cloned().fold(f32::MIN, f32::max) > -30.0); +} + +#[test] +fn lofi_quantizes() { + let mut p = prepared("lofi"); + p.set_param("bits", 2.0); + p.set_param("sample_rate", 48_000.0); + let out = run(p.as_mut(), &[sine(100.0, 0.9, 4800)]); + let mut levels: Vec = out[0].iter().map(|v| (v * 1000.0).round() as i32).collect(); + levels.sort(); + levels.dedup(); + assert!(levels.len() <= 5, "{levels:?}"); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 00000000..6e13cfc7 --- /dev/null +++ b/crates/engine/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "soundcraft-engine" +description = "SoundCraft command engine: commands, undo, editing operations, import/export, bounce, parity catalog" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +soundcraft-time.workspace = true +soundcraft-audio-io.workspace = true +soundcraft-midi.workspace = true +soundcraft-dsp.workspace = true +soundcraft-model.workspace = true +soundcraft-mix.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +log.workspace = true + +[dev-dependencies] +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/engine/catalog/menus.txt b/crates/engine/catalog/menus.txt new file mode 100644 index 00000000..98799aec --- /dev/null +++ b/crates/engine/catalog/menus.txt @@ -0,0 +1,512 @@ +File > New... +File > Open > Session... +File > Open > Project... +File > Open > Sketch... +File > Dashboard... +File > Close Session +File > Save Session +File > Save Session As... +File > Save Session Copy In... +File > Save As Template... +File > Revert Session to Saved... +File > Start Collaboration... +File > Send To... > Sibelius +File > Bounce Mix... +File > Import > Session Data... +File > Import > Audio... +File > Import > MIDI... +File > Import > Video... +File > Import > Clip Groups... +File > Import > VENUE Channel Names as Track Names +File > Export > Selected Tracks as New AAF/OMF... +File > Export > Selected Tracks as New Session... +File > Export > Selected Tracks as New Media Composer Compatible Session... +File > Export > MIDI... +File > Export > Sibelius... +File > Export > Session Info as Text... +File > Get Info... +File > Score Setup... +File > Print Score... +File > Sign In +Edit > Can't Undo +Edit > Can't Redo +Edit > Restore Last Selection +Edit > Cut +Edit > Copy +Edit > Paste +Edit > Clear +Edit > Cut Special > Clip Gain +Edit > Cut Special > Clip Effects +Edit > Cut Special > All Automation +Edit > Cut Special > Pan Automation +Edit > Cut Special > Plugin Automation +Edit > Copy Special > Clip Gain +Edit > Copy Special > Clip Effects +Edit > Copy Special > All Automation +Edit > Copy Special > Pan Automation +Edit > Copy Special > Plugin Automation +Edit > Copy Special > Audio as MIDI +Edit > Paste Special > Merge MIDI +Edit > Paste Special > Merge Markers +Edit > Paste Special > Repeat to Fill Selection +Edit > Paste Special > To Current Automation Type +Edit > Clear Special > Clip Gain +Edit > Clear Special > Clip Effects +Edit > Clear Special > All Automation +Edit > Clear Special > Pan Automation +Edit > Clear Special > Plugin Automation +Edit > Select All +Edit > Selection > Change Timeline to Match Edit +Edit > Selection > Change Edit to Match Timeline +Edit > Selection > Play Edit +Edit > Selection > Play Timeline +Edit > Selection > Move Edit Left +Edit > Selection > Move Edit Right +Edit > Selection > Halve Edit +Edit > Selection > Double Edit +Edit > Selection > Duplicate and Extend Edit +Edit > Selection > Extend Edit Up +Edit > Selection > Extend Edit Down +Edit > Selection > Extend Edit Up to Members +Edit > Selection > Extend Edit Down to Members +Edit > Selection > Remove Edit from Top +Edit > Selection > Remove Edit from Bottom +Edit > Selection > Move Edit Selection To Marker Lane Above +Edit > Selection > Move Edit Selection To Marker Lane Below +Edit > Selection > Extend Edit Selection To Marker Lane Above +Edit > Selection > Extend Edit Selection To Marker Lane Below +Edit > Duplicate +Edit > Repeat... +Edit > Shift... +Edit > Space Clips… +Edit > Insert Silence +Edit > Snap to > Next +Edit > Snap to > Previous +Edit > Trim > To Selection +Edit > Trim > Start to Insertion +Edit > Trim > End to Insertion +Edit > Trim > Start to Fill Selection +Edit > Trim > End to Fill Selection +Edit > Trim > To Fill Selection +Edit > Trim > To File Start +Edit > Trim > To File End +Edit > Trim > To File Boundaries +Edit > Separate > At Selection +Edit > Separate > On Grid +Edit > Separate > At Transients +Edit > Heal Separation +Edit > Consolidate +Edit > Mute +Edit > Copy Selection to... > Target Playlist +Edit > Copy Selection to... > New Playlist +Edit > Copy Selection to... > Duplicate of Main Playlist +Edit > Move Selection to... > Target Playlist +Edit > Move Selection to... > New Playlist +Edit > Move Selection to... > Duplicate of Main Playlist +Edit > Strip Silence +Edit > TCE Edit to Timeline Selection +Edit > Automation > Copy to Send... +Edit > Automation > Thin +Edit > Automation > Thin All +Edit > Automation > Convert Volume Automation to Clip Gain +Edit > Automation > Convert Clip Gain to Volume Automation +Edit > Automation > Coalesce Volume Automation to Clip Gain +Edit > Automation > Coalesce Clip Gain to Volume Automation +Edit > Automation > Write to Current +Edit > Automation > Write to All Enabled +Edit > Automation > Trim to Current +Edit > Automation > Trim to All Enabled +Edit > Automation > Glide to Current +Edit > Automation > Glide to All Enabled +Edit > Automation > Glide Pan Only +Edit > Automation > Duplicate Dolby Atmos Plugin Automation to Pan Automation +Edit > Automation > Duplicate Pan Automation to Dolby Atmos Plugin Automation +Edit > Automation > Coalesce Pan Guide Automation +Edit > Fades > Create... +Edit > Fades > Delete +Edit > Fades > Fade to Start +Edit > Fades > Fade to End +View > Mix Window Views > Mic Preamps +View > Mix Window Views > Instruments +View > Mix Window Views > HEAT +View > Mix Window Views > Inserts A-E +View > Mix Window Views > Inserts F-J +View > Mix Window Views > Sends A-E +View > Mix Window Views > Sends F-J +View > Mix Window Views > EQ Curve +View > Mix Window Views > I/O +View > Mix Window Views > Object +View > Mix Window Views > Meters and Faders +View > Mix Window Views > Delay Compensation +View > Mix Window Views > Track Color +View > Mix Window Views > Comments +View > Mix Window Views > All +View > Mix Window Views > Minimal +View > Narrow Mix +View > Edit Window Views > Comments +View > Edit Window Views > Mic Preamps +View > Edit Window Views > Instruments +View > Edit Window Views > Inserts A-E +View > Edit Window Views > Inserts F-J +View > Edit Window Views > Sends A-E +View > Edit Window Views > Sends F-J +View > Edit Window Views > I/O +View > Edit Window Views > Object +View > Edit Window Views > Real-Time Properties +View > Edit Window Views > Track Color +View > Edit Window Views > Marker Controls +View > Edit Window Views > All +View > Edit Window Views > Minimal +View > Edit Window Views > Pinned Tracks +View > Rulers > Bars|Beats +View > Rulers > Min:Secs +View > Rulers > Timecode +View > Rulers > Timecode 2 +View > Rulers > Feet+Frames +View > Rulers > Samples +View > Rulers > Tempo > Tempo Editor +View > Rulers > Meter +View > Rulers > Key Signature > Key Signature Staff +View > Rulers > Chord Symbols +View > Rulers > Markers +View > Rulers > Markers 2 +View > Rulers > Markers 3 +View > Rulers > Markers 4 +View > Rulers > Markers 5 +View > Rulers > All Marker Rulers +View > Rulers > All +View > Rulers > Minimal +View > Marker Displays > Track Lane +View > Marker Displays > Ruler Lines +View > Marker Displays > Folder Lines +View > Marker Displays > Ruler Lane Color +View > Marker Displays > Track Lane Color +View > Other Displays > Universe +View > Other Displays > Track List +View > Other Displays > Clip List +View > Other Displays > MIDI Input Display +View > Other Displays > Lower Dock > MIDI Editor > Clip Effects +View > Clip > Sync Point +View > Clip > Processing State +View > Clip > Name +View > Clip > Channel Name +View > Clip > Scene And Take +View > Clip > Rating +View > Clip > Overlap Shadows +View > Clip > Transparency +View > Clip > Overwrite Indicator +View > Clip > Copy/Move Indicators +View > Clip > Clip Gain Line +View > Clip > Clip Gain Info +View > Clip > Clip Effects Status +View > Clip > ARA Note Overlay +View > Clip > Current Time +View > Clip > Original Time Stamp +View > Clip > User Time Stamp +View > Clip > No Time +View > Clip > Display on All Channels +View > Waveforms > Peak +View > Waveforms > Power +View > Waveforms > Rectified +View > Waveforms > Outlines +View > Waveforms > Overlapped Crossfades +View > Automation > Trim Playlist +View > Automation > Composite Playlist +View > Expanded Sends > All +View > Expanded Sends > None +View > Expanded Sends > Send A +View > Expanded Sends > Send B +View > Expanded Sends > Send C +View > Expanded Sends > Send D +View > Expanded Sends > Send E +View > Expanded Sends > Send F +View > Expanded Sends > Send G +View > Expanded Sends > Send H +View > Expanded Sends > Send I +View > Expanded Sends > Send J +View > Track Number +View > Track Transcription Lane +View > Dropped Video Frame Indicator +View > Transport > Counters +View > Transport > MIDI Controls +View > Transport > Synchronization +View > Transport > Ableton Link +View > Transport > Output Meters +View > Transport > Expanded +View > Main Counter > Bars|Beats +View > Main Counter > Min:Secs +View > Main Counter > Timecode +View > Main Counter > Feet+Frames +View > Main Counter > Samples +Track > New... +Track > Group... +Track > Duplicate... +Track > Split into Mono +Track > Make Inactive +Track > Move to New Folder... +Track > Convert Aux to Routing Folder +Track > Change Track Width > Mono +Track > Change Track Width > Stereo +Track > Change Track Width > LCR +Track > Change Track Width > Quad +Track > Change Track Width > LCRS +Track > Change Track Width > 5.0 +Track > Change Track Width > 5.1 +Track > Change Track Width > 6.0 +Track > Change Track Width > 6.1 +Track > Change Track Width > 7.0 SDDS +Track > Change Track Width > 7.1 SDDS +Track > Change Track Width > 7.0 +Track > Change Track Width > 7.1 +Track > Change Track Width > 7.0.2 +Track > Change Track Width > 7.1.2 +Track > Change Track Width > 1st Order Ambisonics +Track > Change Track Width > 2nd Order Ambisonics +Track > Change Track Width > 3rd Order Ambisonics +Track > Change Track Width > 4th Order Ambisonics +Track > Change Track Width > 5th Order Ambisonics +Track > Change Track Width > 6th Order Ambisonics +Track > Change Track Width > 7th Order Ambisonics +Track > Change Track Width > 5.0.2 +Track > Change Track Width > 5.1.2 +Track > Change Track Width > 5.0.4 +Track > Change Track Width > 5.1.4 +Track > Change Track Width > 7.0.4 +Track > Change Track Width > 7.1.4 +Track > Change Track Width > 7.0.6 +Track > Change Track Width > 7.1.6 +Track > Change Track Width > 9.0.4 +Track > Change Track Width > 9.1.4 +Track > Change Track Width > 9.0.6 +Track > Change Track Width > 9.1.6 +Track > Change Track Width > Show / Hide... +Track > Delete +Track > Freeze +Track > Commit... +Track > Bounce... +Track > Extract MIDI to New Track +Track > ARA Plugins > Melodyne +Track > Save Track Preset... +Track > Bypass Inserts > All +Track > Bypass Inserts > Inserts A-E +Track > Bypass Inserts > Inserts F-J +Track > Bypass Inserts > EQ +Track > Bypass Inserts > Dynamics +Track > Bypass Inserts > Reverb +Track > Bypass Inserts > Delay +Track > Bypass Inserts > Modulation +Track > Mute Sends > All +Track > Mute Sends > Sends A-E +Track > Mute Sends > Sends F-J +Track > Write MIDI Real-Time Properties +Track > Set Record Tracks to Input Only +Track > Scroll to Track... +Track > Clear All Clip Indicators +Track > Coalesce VCA Controller Automation +Track > Coalesce Trim Automation +Track > Clear Trim Automation +Track > Designate as Target Playlist +Track > Show Target Playlist +Track > Toggle Recent Playlist +Track > Create Click Track +Clip > Edit Lock/Unlock +Clip > Time Lock/Unlock +Clip > Send to Back +Clip > Bring to Front +Clip > Rating > none +Clip > Rating > 1 +Clip > Rating > 2 +Clip > Rating > 3 +Clip > Rating > 4 +Clip > Rating > 5 +Clip > Group +Clip > Ungroup +Clip > Ungroup All +Clip > Regroup +Clip > Loop... +Clip > Unloop... +Clip > Capture... +Clip > Rename... +Clip > Identify Sync Point +Clip > Quantize to Grid +Clip > Clip Gain > Bypass +Clip > Clip Gain > Render +Clip > Clip Effects > Bypass +Clip > Clip Effects > Render +Clip > ARA Plugins > Melodyne +Clip > Elastic Properties +Clip > Conform to Tempo +Clip > Remove Warp +Clip > Remove Pitch Shift +Event > Time Operations > Time Operations Window +Event > Time Operations > Change Meter... +Event > Time Operations > Insert Time... +Event > Time Operations > Cut Time... +Event > Time Operations > Move Song Start... +Event > Tempo Operations > Tempo Operations Window +Event > Tempo Operations > Constant... +Event > Tempo Operations > Linear... +Event > Tempo Operations > Parabolic... +Event > Tempo Operations > S-Curve... +Event > Tempo Operations > Scale... +Event > Tempo Operations > Stretch... +Event > MIDI Operations > MIDI Operations Window +Event > MIDI Operations > Quantize... +Event > MIDI Operations > Change Velocity... +Event > MIDI Operations > Change Duration... +Event > MIDI Operations > Transpose... +Event > MIDI Operations > Select/Split Notes... +Event > MIDI Operations > Input Quantize... +Event > MIDI Operations > Step Input... +Event > MIDI Operations > Restore Performance... +Event > MIDI Operations > Flatten Performance... +Event > MIDI Track Offsets +Event > MIDI Real-Time Properties +Event > Remove Duplicate Notes +Event > Add Key Change... +Event > Extract Chords from Selection +Event > Beat Detective +Event > Identify Beat... +Event > Renumber Bars... +Event > All MIDI Notes Off +Event > Retrospective Record +AudioSuite > EQ > Channel Strip +AudioSuite > EQ > EQ3 1-Band +AudioSuite > EQ > EQ3 7-Band +AudioSuite > Dynamics > BF-76 +AudioSuite > Dynamics > Channel Strip +AudioSuite > Dynamics > Dyn3 Compressor/Limiter +AudioSuite > Dynamics > Dyn3 De-Esser +AudioSuite > Dynamics > Dyn3 Expander/Gate +AudioSuite > Dynamics > Maxim +AudioSuite > Pitch Shift > Pitch II +AudioSuite > Pitch Shift > Pitch Shift Legacy +AudioSuite > Pitch Shift > Time Shift +AudioSuite > Pitch Shift > Vari-Fi +AudioSuite > Reverb > D-Verb +AudioSuite > Delay > Mod Delay III +AudioSuite > Modulation > Sci-Fi +AudioSuite > Harmonic > Eleven Lite +AudioSuite > Harmonic > Lo-Fi +AudioSuite > Harmonic > Recti-Fi +AudioSuite > Harmonic > SansAmp PSA-1 +AudioSuite > Other > DC Offset Removal +AudioSuite > Other > Duplicate +AudioSuite > Other > Gain +AudioSuite > Other > Invert +AudioSuite > Other > Normalize +AudioSuite > Other > Reverse +AudioSuite > Other > Signal Generator +AudioSuite > Other > Time Compression Expansion +Options > Destructive Record +Options > Loop Record +Options > QuickPunch +Options > TrackPunch +Options > DestructivePunch +Options > Prepare DPE Tracks +Options > Transport Online +Options > Video Track Online +Options > Pre/Post-Roll +Options > Loop Playback +Options > Dynamic Transport +Options > Edit Window Scrolling > No Scrolling +Options > Edit Window Scrolling > After Playback +Options > Edit Window Scrolling > Page +Options > Edit Window Scrolling > Continuous +Options > Edit Window Scrolling > Center Playhead +Options > Link Timeline and Edit Selection +Options > Link Track and Edit Selection +Options > Insertion Follows Playback +Options > Tab to Transient +Options > Mirrored MIDI Editing +Options > Automation Follows Edit +Options > Markers Follow Edit +Options > Marker Target Follows First Selected Track +Options > Layered Editing +Options > Click +Options > MIDI Thru +Options > Auto-Spot Clips +Options > Pre-Fader Metering +Options > Solo Mode > SIP (Solo In Place) +Options > Solo Mode > AFL (After Fader Listen) +Options > Solo Mode > PFL (Pre Fader Listen) +Options > Solo Mode > Latch +Options > Solo Mode > X-OR (Cancels Previous Solo) +Options > Solo Mode > Momentary +Options > Edit/Tool Mode Keyboard Lock +Options > Calibration Mode +Options > Delay Compensation +Options > MIDI Live Mode +Options > Activate HEAT +Options > Low Latency Monitoring +Setup > Hardware... +Setup > Playback Engine... +Setup > Disk Allocation... +Setup > Peripherals... +Setup > I/O... +Setup > Machine Track Arming Profiles... +Setup > Video Sync Offset... +Setup > Session +Setup > Current Feet+Frames Position... +Setup > Current Timecode Position... +Setup > External Timecode Offset... +Setup > Transcription Settings... +Setup > MIDI > MIDI Studio... +Setup > MIDI > MIDI Beat Clock... +Setup > MIDI > MIDI Input Filter... +Setup > MIDI > MIDI Input Devices... +Setup > Click/Countoff... +Setup > Keyboard Shortcuts... +Setup > Preferences... +Window > Configurations > Window Configuration List +Window > Configurations > New Configuration... +Window > Configurations > Update Active Configuration +Window > Configurations > Auto-Update Active Configuration +Window > Arrange > Tile +Window > Arrange > Tile Horizontal +Window > Arrange > Tile Vertical +Window > Arrange > Cascade +Window > Hide All Floating Windows +Window > Close Window +Window > Mix +Window > Edit +Window > Sketch to Front +Window > MIDI Editor +Window > Score Editor +Window > MIDI Event List +Window > MIDI Editors > Bring to Front +Window > MIDI Editors > Send to Back +Window > MIDI Keyboard +Window > Clip List +Window > Clip Effects +Window > ARA Plugins +Window > Splice +Window > Learn +Window > SoundFlow +Window > Task Manager +Window > New Workspace > Default +Window > New Workspace > Soundbase +Window > New Workspace > Track Presets +Window > Workspaces > Bring to Front +Window > Workspaces > Send to Back +Window > Workspaces > Close All Workspaces +Window > Transport +Window > Big Counter +Window > Automation +Window > Memory Locations +Window > Machine Track Arming +Window > Video Universe +Window > Video +Window > Color Palette +Window > UI Customization +Window > Metadata Inspector +Window > Undo History +Window > Transcript +Window > Pro Tools Search +Window > Renderer +Window > Trim and Downmix +Window > Collaboration > Pro Tools Projects +Window > Collaboration > Find Talent +Window > Collaboration > Messages +Window > Disk Usage +Window > System Usage diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs new file mode 100644 index 00000000..60bbc98f --- /dev/null +++ b/crates/engine/src/catalog.rs @@ -0,0 +1,133 @@ +//! The incumbent's menu tree (feature names only, observed black-box) and the parity measure. +//! +//! A catalog entry counts as implemented when a registered command has the same menu path and +//! label, or when [`ALIASES`] maps the entry to a command id (for UI-layer commands, the UI crate +//! registers extra ids through [`parity_with`]). + +use crate::cmd::command_specs; +use serde_json::{Value, json}; + +/// One leaf per line: `Menu > Submenu > Item`. +pub const MENUS: &str = include_str!("../catalog/menus.txt"); + +/// Catalog path → command id, for entries whose label or menu differs from ours. +pub const ALIASES: &[(&str, &str)] = &[ + ("File > Open > Session...", "session.open"), + ("Edit > Can't Undo", "edit.undo"), + ("Edit > Can't Redo", "edit.redo"), + ("Edit > Selection > Play Edit", "transport.play_selection"), + ("Edit > Selection > Change Timeline to Match Edit", "edit.selection_to_timeline"), + ("Edit > Selection > Change Edit to Match Timeline", "edit.timeline_to_selection"), + ("Edit > Copy Selection to... > New Playlist", "edit.copy_to_new_playlist"), + ("Edit > Paste Special > Repeat to Fill Selection", "edit.paste_repeat_to_fill"), + ("Edit > Trim > Start to Fill Selection", "edit.trim_to_fill_selection"), + ("Edit > Trim > End to Fill Selection", "edit.trim_to_fill_selection"), + ("Edit > Trim > To File Start", "edit.trim_to_file_boundaries"), + ("Edit > Trim > To File End", "edit.trim_to_file_boundaries"), + ("Edit > Automation > Glide to Current", "automation.write_range"), + ("Edit > Automation > Trim to Current", "automation.write_to_current"), + ("Track > Bypass Inserts > All", "track.bypass_inserts"), + ("Track > Mute Sends > All", "track.mute_sends"), + ("Clip > Rating > none", "clip.rating"), + ("Clip > Rating > 1", "clip.rating"), + ("Clip > Rating > 2", "clip.rating"), + ("Clip > Rating > 3", "clip.rating"), + ("Clip > Rating > 4", "clip.rating"), + ("Clip > Rating > 5", "clip.rating"), + ("Clip > Clip Gain > Bypass", "clip.gain_bypass"), + ("Clip > Clip Gain > Render", "clip.gain_render"), + ("Event > Time Operations > Change Meter...", "event.meter"), + ("Event > Tempo Operations > Constant...", "event.tempo_constant"), + ("Event > Tempo Operations > Scale...", "event.tempo_scale"), + ("Options > Solo Mode > SIP (Solo In Place)", "options.solo_mode"), + ("Options > Solo Mode > AFL (After Fader Listen)", "options.solo_mode"), + ("Options > Solo Mode > PFL (Pre Fader Listen)", "options.solo_mode"), + ("Options > Solo Mode > X-OR (Cancels Previous Solo)", "options.solo_mode"), + ("Options > Solo Mode > Latch", "options.solo_mode"), + ("Options > Solo Mode > Momentary", "options.solo_mode"), + ("Options > Edit Window Scrolling > No Scrolling", "options.scrolling"), + ("Options > Edit Window Scrolling > After Playback", "options.scrolling"), + ("Options > Edit Window Scrolling > Page", "options.scrolling"), + ("Options > Edit Window Scrolling > Continuous", "options.scrolling"), + ("Options > Edit Window Scrolling > Center Playhead", "options.scrolling"), + ("View > Main Counter > Bars|Beats", "view.main_counter"), + ("View > Main Counter > Min:Secs", "view.main_counter"), + ("View > Main Counter > Timecode", "view.main_counter"), + ("View > Main Counter > Feet+Frames", "view.main_counter"), + ("View > Main Counter > Samples", "view.main_counter"), + ("View > Rulers > Bars|Beats", "view.ruler"), + ("View > Rulers > Min:Secs", "view.ruler"), + ("View > Rulers > Timecode", "view.ruler"), + ("View > Rulers > Feet+Frames", "view.ruler"), + ("View > Rulers > Samples", "view.ruler"), + ("View > Rulers > Meter", "view.ruler"), + ("View > Rulers > Markers", "view.ruler"), + ("View > Rulers > Chord Symbols", "view.ruler"), + ("Window > Undo History", "engine.history"), +]; + +pub fn catalog() -> Vec<&'static str> { + MENUS.lines().map(str::trim).filter(|l| !l.is_empty()).collect() +} + +/// Command id implementing a catalog entry, if any. +pub fn implemented_by(entry: &str, extra: &[(&str, &str)]) -> Option { + if let Some((_, id)) = ALIASES.iter().chain(extra.iter()).find(|(p, _)| *p == entry) { + return Some((*id).to_string()); + } + let parts: Vec<&str> = entry.split(" > ").collect(); + let (label, menu) = parts.split_last()?; + command_specs().iter().find(|c| c.label == *label && c.menu == menu).map(|c| c.id.to_string()) +} + +/// Parity over the catalog, with extra UI-layer mappings. +pub fn parity_with(extra: &[(&str, &str)]) -> Value { + let cat = catalog(); + let mut done = 0usize; + let mut missing = Vec::new(); + let mut per_menu: std::collections::BTreeMap = Default::default(); + for e in &cat { + let top = e.split(" > ").next().unwrap_or("").to_string(); + let slot = per_menu.entry(top).or_default(); + slot.1 += 1; + if implemented_by(e, extra).is_some() { + done += 1; + slot.0 += 1; + } else { + missing.push(*e); + } + } + let pct = if cat.is_empty() { 0.0 } else { done as f64 * 100.0 / cat.len() as f64 }; + json!({ + "total": cat.len(), + "implemented": done, + "percent": (pct * 10.0).round() / 10.0, + "per_menu": per_menu.iter().map(|(k, (d, t))| json!({"menu": k, "implemented": d, "total": t})).collect::>(), + "missing": missing, + }) +} + +pub fn parity_json() -> Value { + parity_with(&[]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aliases_point_at_real_commands() { + for (path, id) in ALIASES { + assert!(crate::find_command(id).is_some(), "{path} → unknown {id}"); + assert!(catalog().contains(path), "alias for unknown catalog entry {path}"); + } + } + + #[test] + fn parity_floor() { + let p = parity_json(); + let pct = p["percent"].as_f64().unwrap_or(0.0); + // The floor only ever rises. + assert!(pct >= 25.0, "engine parity regressed: {pct}%"); + } +} diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs new file mode 100644 index 00000000..7e0079ff --- /dev/null +++ b/crates/engine/src/cmd/clip.rs @@ -0,0 +1,249 @@ +//! Clip menu commands. + +use super::*; +use crate::cmd; +use serde_json::json; + +pub fn specs() -> Vec { + vec![ + cmd!("clip.edit_lock", "Edit Lock/Unlock", ["Clip"], Some("Cmd+L"), "{clips?}", has_selection, |e, p| flag( + e, + p, + |c| c.edit_locked, + |c, v| c.edit_locked = v + )), + cmd!("clip.time_lock", "Time Lock/Unlock", ["Clip"], Some("Ctrl+Alt+L"), "{clips?}", has_selection, |e, p| flag( + e, + p, + |c| c.time_locked, + |c, v| c.time_locked = v + )), + cmd!("clip.send_to_back", "Send to Back", ["Clip"], Some("Alt+Shift+B"), "{clips?}", has_selection, |e, p| layer(e, p, false)), + cmd!("clip.bring_to_front", "Bring to Front", ["Clip"], Some("Alt+Shift+F"), "{clips?}", has_selection, |e, p| layer(e, p, true)), + cmd!("clip.rating", "Rating", ["Clip", "Rating"], Some("Cmd+Alt+0-5"), "{clips?, rating: 0..5}", has_selection, |e, p| { + let ids = clip_ids_param(e, p); + let r = i64_or(p, "rating", 0).clamp(0, 5) as u8; + let s = e.session_mut(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.rating = r + } + } + Ok(json!({"rating": r})) + }), + cmd!("clip.group", "Group", ["Clip"], Some("Cmd+Alt+G"), "{clips?}", has_selection, |e, p| { + let ids = clip_ids_param(e, p); + if ids.len() < 2 { + return Err(bad("clip.group", "select two or more clips")); + } + let s = e.session_mut(); + let g = s.alloc(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.group = Some(g) + } + } + Ok(json!({"group": g})) + }), + cmd!("clip.ungroup", "Ungroup", ["Clip"], Some("Cmd+Alt+U"), "{clips?}", has_selection, |e, p| { + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.group = None + } + } + Ok(json!({})) + }), + cmd!("clip.ungroup_all", "Ungroup All", ["Clip"], None, "{}", always, |e, _| { + for t in &mut e.session_mut().tracks { + for pl in &mut t.playlists { + for c in &mut pl.clips { + c.group = None + } + } + } + Ok(json!({})) + }), + cmd!("clip.loop", "Loop...", ["Clip"], Some("Cmd+Alt+L"), "{clips?, count?: n, length?: samples}", has_selection, loop_clips), + cmd!("clip.rename", "Rename...", ["Clip"], Some("Cmd+Alt+Shift+R"), "{clip?, name}", has_selection, rename), + cmd!("clip.gain", "Clip Gain", [], None, "{clips?, db | delta_db}", has_selection, gain), + cmd!("clip.gain_render", "Render", ["Clip", "Clip Gain"], None, "{clips?}", has_selection, |e, p| { + let ids = clip_ids_param(e, p); + let mut n = 0; + for id in ids { + if crate::io::render_clip_gain(e, id)? { + n += 1 + } + } + Ok(json!({"rendered": n})) + }), + cmd!("clip.gain_bypass", "Bypass", ["Clip", "Clip Gain"], None, "{clips?}", has_selection, |e, p| { + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.gain_db = 0.0; + c.gain_env.clear() + } + } + Ok(json!({})) + }), + cmd!("clip.identify_sync_point", "Identify Sync Point", ["Clip"], Some("Cmd+,"), "{clip?, at?}", has_selection, |e, p| { + let at = position_param(e, "clip.identify_sync_point", p, "at")?.unwrap_or(e.session().edit.selection.start); + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.sync_point = (at - c.start).clamp(0, c.length) + } + } + Ok(json!({})) + }), + cmd!("clip.quantize_to_grid", "Quantize to Grid", ["Clip"], Some("Cmd+0"), "{clips?}", has_selection, quantize_to_grid), + cmd!("clip.color", "Clip Color", [], None, "{clips?, color: [r,g,b] | null}", has_selection, |e, p| { + let ids = clip_ids_param(e, p); + let col = p.get("color").and_then(Value::as_array).map(|a| { + let g = |i: usize| a.get(i).and_then(Value::as_u64).unwrap_or(0).min(255) as u8; + [g(0), g(1), g(2)] + }); + let s = e.session_mut(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.color = col + } + } + Ok(json!({})) + }), + cmd!("clip.elastic_properties", "Elastic Properties", ["Clip"], Some("Alt+5"), "{clips?, ratio?: 1.0}", has_selection, |e, p| { + let ids = clip_ids_param(e, p); + let r = f64_or(p, "ratio", 1.0).clamp(0.05, 20.0); + let s = e.session_mut(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.stretch = r + } + } + Ok(json!({"ratio": r})) + }), + cmd!("clip.remove_warp", "Remove Warp", ["Clip"], None, "{clips?}", has_selection, |e, p| { + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.stretch = 1.0 + } + } + Ok(json!({})) + }), + cmd!("clip.capture", "Capture...", ["Clip"], Some("Cmd+R"), "{name?}", has_range, |e, p| { + // Captures the selection as a new whole-file-referencing clip in the clip list (we keep it on the track). + let name = str_param(p, "name").unwrap_or("Captured").to_string(); + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.name = name.clone() + } + } + Ok(json!({"clips": ids.len()})) + }), + ] +} + +fn flag(e: &mut Engine, p: &Value, get: fn(&soundcraft_model::Clip) -> bool, set: fn(&mut soundcraft_model::Clip, bool)) -> Result { + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !ids.iter().all(|id| s.find_clip(*id).is_some_and(|(_, c)| get(c)))); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + set(c, v); + } + } + Ok(json!({"value": v, "clips": ids.len()})) +} + +fn layer(e: &mut Engine, p: &Value, front: bool) -> Result { + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + for t in &mut s.tracks { + if let Some(pl) = t.playlist_mut() { + let (mut sel, mut rest): (Vec<_>, Vec<_>) = pl.clips.drain(..).partition(|c| ids.contains(&c.id)); + if front { + rest.append(&mut sel); + pl.clips = rest; + } else { + sel.append(&mut rest); + pl.clips = sel; + } + } + } + Ok(json!({})) +} + +fn loop_clips(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + let count = i64_or(p, "count", 4).clamp(1, 512); + let total = position_param(e, "clip.loop", p, "length")?; + let s = e.session_mut(); + let mut made = 0; + for id in ids { + let Some((tid, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; + let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1); + let mut copies = Vec::new(); + for k in 1..=reps { + let mut nc = c.clone(); + nc.start = 0; + copies.push((c.start + c.length * k, nc)); + } + for (at, nc) in copies { + crate::edit::paste(s, tid, at, &[nc], c.length, false); + made += 1; + } + } + Ok(json!({"loops": made})) +} + +fn rename(e: &mut Engine, p: &Value) -> Result { + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("clip.rename", "`name` required"))?.to_string(); + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.name = name.clone(); + } + } + Ok(json!({"clips": ids.len()})) +} + +fn gain(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + let abs = p.get("db").and_then(Value::as_f64); + let delta = p.get("delta_db").and_then(Value::as_f64); + if abs.is_none() && delta.is_none() { + return Err(bad("clip.gain", "`db` or `delta_db` required")); + } + let s = e.session_mut(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + let v = abs.unwrap_or(f64::from(c.gain_db) + delta.unwrap_or(0.0)); + c.gain_db = if v.is_finite() { (v as f32).clamp(-144.0, 36.0) } else { c.gain_db }; + } + } + Ok(json!({"clips": ids.len()})) +} + +fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + let s = e.session(); + let moves: Vec<(soundcraft_model::ClipId, i64)> = ids + .iter() + .filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, c.start + c.sync_point))) + .map(|(id, anchor)| (id, s.edit.grid.snap(anchor, s.sample_rate, &s.tempo, s.frame_rate) - anchor)) + .collect(); + let s = e.session_mut(); + for (id, d) in &moves { + crate::edit::move_clips(s, &[*id], *d, None); + } + Ok(json!({"clips": moves.len()})) +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs new file mode 100644 index 00000000..888b0066 --- /dev/null +++ b/crates/engine/src/cmd/edit.rs @@ -0,0 +1,726 @@ +//! Edit menu commands. + +use super::*; +use crate::edit as ops; +use crate::{Clipboard, cmd}; +use serde_json::json; +use soundcraft_model::{EditMode, FadeShape, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |e, _| Ok(json!({"undone": e.undo()}))), + cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |e, _| Ok(json!({"redone": e.redo()}))), + cmd!("edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", always, restore_selection), + cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, true, "edit.cut")), + cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, false, "edit.copy")), + cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{tracks?, at?}", has_clipboard, paste), + cmd!("edit.clear", "Clear", ["Edit"], Some("Cmd+B"), "{tracks?, start?, end?}", has_range, clear), + cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| reset_clip_gain( + e, + p, + "edit.cut_clip_gain" + )), + cmd!("edit.clear_clip_gain", "Clip Gain", ["Edit", "Clear Special"], Some("Cmd+Ctrl+B"), "{}", has_range, |e, p| reset_clip_gain( + e, + p, + "edit.clear_clip_gain" + )), + cmd!("edit.cut_all_automation", "All Automation", ["Edit", "Cut Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)), + cmd!("edit.clear_all_automation", "All Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)), + cmd!("edit.clear_pan_automation", "Pan Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation( + e, + p, + Some("pan") + )), + cmd!("edit.clear_plugin_automation", "Plugin Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation( + e, + p, + Some("plugin") + )), + cmd!( + "edit.paste_repeat_to_fill", + "Repeat to Fill Selection", + ["Edit", "Paste Special"], + Some("Cmd+Alt+V"), + "{}", + has_clipboard, + repeat_to_fill + ), + cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all), + cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id]}", always, select), + cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| { + let s = e.session_mut(); + s.edit.selected_tracks.clear(); + s.edit.selected_clips.clear(); + s.edit.selection.end = s.edit.selection.start; + Ok(json!({})) + }), + cmd!(noundo "edit.selection_to_timeline", "Change Timeline to Match Edit", ["Edit", "Selection"], Some("Alt+Shift+5"), "{}", always, |e, _| { + let s = e.session_mut(); + s.edit.timeline_selection = s.edit.selection; + Ok(json!({})) + }), + cmd!(noundo "edit.timeline_to_selection", "Change Edit to Match Timeline", ["Edit", "Selection"], Some("Alt+Shift+6"), "{}", always, |e, _| { + let s = e.session_mut(); + s.edit.selection = s.edit.timeline_selection; + Ok(json!({})) + }), + cmd!(noundo "edit.move_selection_left", "Move Edit Left", ["Edit", "Selection"], Some("Cmd+Alt+L"), "{}", always, |e, _| shift_selection(e, -1)), + cmd!(noundo "edit.move_selection_right", "Move Edit Right", ["Edit", "Selection"], Some("Cmd+Alt+'"), "{}", always, |e, _| shift_selection(e, 1)), + cmd!(noundo "edit.halve_selection", "Halve Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 0.5)), + cmd!(noundo "edit.double_selection", "Double Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 2.0)), + cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, -1)), + cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, 1)), + cmd!(noundo "edit.remove_selection_top", "Remove Edit from Top", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| { + let s = e.session_mut(); + if !s.edit.selected_tracks.is_empty() { + s.edit.selected_tracks.remove(0); + } + Ok(json!({})) + }), + cmd!(noundo "edit.remove_selection_bottom", "Remove Edit from Bottom", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| { + e.session_mut().edit.selected_tracks.pop(); + Ok(json!({})) + }), + cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{tracks?, start?, end?}", has_range, duplicate), + cmd!("edit.repeat", "Repeat...", ["Edit"], Some("Alt+R"), "{count: n}", has_range, repeat), + cmd!("edit.shift", "Shift...", ["Edit"], Some("Alt+H"), "{by: samples|{seconds}, later?: bool}", has_range, shift), + cmd!("edit.insert_silence", "Insert Silence", ["Edit"], Some("Cmd+Shift+E"), "{tracks?, start?, end?}", has_range, insert_silence), + cmd!("edit.separate", "At Selection", ["Edit", "Separate"], Some("Cmd+E"), "{tracks?, at?|start/end}", has_selection, separate), + cmd!("edit.separate_on_grid", "On Grid", ["Edit", "Separate"], None, "{tracks?, start?, end?}", has_range, separate_on_grid), + cmd!("edit.separate_at_transients", "At Transients", ["Edit", "Separate"], None, "{sensitivity?: 0..1}", has_range, separate_at_transients), + cmd!("edit.heal", "Heal Separation", ["Edit"], Some("Cmd+H"), "{tracks?, start?, end?}", has_selection, heal), + cmd!("edit.trim_to_selection", "To Selection", ["Edit", "Trim"], Some("Cmd+T"), "{tracks?, start?, end?}", has_range, trim_to_selection), + cmd!("edit.trim_start_to_insertion", "Start to Insertion", ["Edit", "Trim"], Some("Alt+Shift+7"), "{tracks?, at?}", has_selection, |e, p| { + trim_edge(e, p, true) + }), + cmd!("edit.trim_end_to_insertion", "End to Insertion", ["Edit", "Trim"], Some("Alt+Shift+8"), "{tracks?, at?}", has_selection, |e, p| { + trim_edge(e, p, false) + }), + cmd!("edit.trim_to_file_boundaries", "To File Boundaries", ["Edit", "Trim"], None, "{clips?}", has_selection, trim_to_file), + cmd!("edit.trim_to_fill_selection", "To Fill Selection", ["Edit", "Trim"], None, "{}", has_range, trim_to_fill), + cmd!("edit.mute_clips", "Mute", ["Edit"], Some("Cmd+M"), "{clips?}", has_selection, mute_clips), + cmd!("edit.copy_to_new_playlist", "New Playlist", ["Edit", "Copy Selection to..."], None, "{}", has_range, copy_to_new_playlist), + cmd!( + "edit.strip_silence", + "Strip Silence", + ["Edit"], + Some("Cmd+U"), + "{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20}", + has_range, + strip_silence + ), + cmd!( + "edit.fades_create", + "Create...", + ["Edit", "Fades"], + Some("Cmd+F"), + "{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?}", + has_range, + fades_create + ), + cmd!("edit.fades_delete", "Delete", ["Edit", "Fades"], None, "{tracks?, start?, end?}", has_selection, fades_delete), + cmd!("edit.fade_to_start", "Fade to Start", ["Edit", "Fades"], Some("Cmd+Ctrl+D"), "{}", has_selection, |e, p| fade_to(e, p, true)), + cmd!("edit.fade_to_end", "Fade to End", ["Edit", "Fades"], Some("Cmd+Ctrl+G"), "{}", has_selection, |e, p| fade_to(e, p, false)), + cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n}", has_selection, nudge), + cmd!(noundo "edit.mode", "Edit Mode", [], Some("F1-F4"), "{mode: shuffle|slip|spot|grid|relative_grid}", always, |e, p| { + let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?; + e.session_mut().edit.edit_mode = m; + Ok(json!({"mode": m})) + }), + cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| { + let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?; + e.session_mut().edit.tool = t; + Ok(json!({"tool": t})) + }), + cmd!( + "edit.move_clips", + "Move Clips", + [], + None, + "{clips?: [id], by?: samples, to?: position (first clip start), track?: target}", + has_selection, + move_clips + ), + cmd!("edit.consolidate", "Consolidate", ["Edit"], Some("Alt+Shift+3"), "{tracks?, start?, end?}", has_range, consolidate), + ] +} + +fn shuffle(s: &Session) -> bool { + s.edit.edit_mode == EditMode::Shuffle +} + +fn restore_selection(e: &mut Engine, _: &Value) -> Result { + Ok(json!({"selection": e.session().edit.selection})) +} + +fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { + let tracks = tracks_required(e, id, p)?; + let range = range_param(e, id, p)?; + if range.is_empty() { + return Err(bad(id, "selection is empty")); + } + let mut cb = Clipboard { length: range.len(), ..Clipboard::default() }; + let follow = e.session().edit.automation_follows_edit; + for t in &tracks { + cb.tracks.push(ops::copy_range(e.session(), *t, range)); + let lanes = e + .session() + .track(*t) + .map(|tr| { + tr.automation + .iter() + .map(|l| { + let mut l2 = l.clone(); + l2.points = l + .points + .iter() + .filter(|p| range.contains(p.at)) + .map(|p| soundcraft_model::AutomationPoint { at: p.at - range.start, value: p.value }) + .collect(); + l2 + }) + .collect() + }) + .unwrap_or_default(); + cb.automation.push(lanes); + } + if cut { + let sh = shuffle(e.session()); + let s = e.session_mut(); + for t in &tracks { + ops::clear_range(s, *t, range, sh); + if follow && let Some(tr) = s.track_mut(*t) { + for l in &mut tr.automation { + l.clear_range(range.start, range.end); + if sh { + l.shift_from(range.end, -range.len()); + } + } + } + } + s.edit.selection = soundcraft_time::Range::point(range.start); + } + let n: usize = cb.tracks.iter().map(Vec::len).sum(); + e.clipboard = cb; + Ok(json!({"clips": n, "length": range.len()})) +} + +fn paste(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.paste", p)?; + let at = position_param(e, "edit.paste", p, "at")?.unwrap_or(e.session().edit.selection.start); + let cb = e.clipboard.clone(); + let sh = shuffle(e.session()); + let follow = e.session().edit.automation_follows_edit; + let s = e.session_mut(); + let mut pasted = 0; + for (i, t) in tracks.iter().enumerate() { + let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) else { continue }; + pasted += ops::paste(s, *t, at, clips, cb.length, sh).len(); + if follow && let (Some(lanes), Some(tr)) = (cb.automation.get(i % cb.automation.len().max(1)), s.track_mut(*t)) { + for l in lanes { + let dst = tr.lane_mut(&l.param); + if !l.points.is_empty() { + dst.clear_range(at, at + cb.length); + } + for pt in &l.points { + dst.set_point(pt.at + at, pt.value); + } + } + } + } + s.edit.selection = soundcraft_time::Range::new(at, at + cb.length); + Ok(json!({"pasted": pasted})) +} + +fn clear(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.clear", p)?; + let range = range_param(e, "edit.clear", p)?; + let ids = clip_ids_param(e, p); + let sh = shuffle(e.session()); + 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(); + } + } + s.edit.selected_clips.clear(); + } else { + for t in &tracks { + n += ops::clear_range(s, *t, range, sh).len(); + } + } + Ok(json!({"removed": n})) +} + +fn reset_clip_gain(e: &mut Engine, p: &Value, _id: &str) -> Result { + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.gain_db = 0.0; + c.gain_env.clear(); + } + } + Ok(json!({"clips": ids.len()})) +} + +fn clear_automation(e: &mut Engine, p: &Value, which: Option<&str>) -> Result { + let tracks = tracks_required(e, "edit.clear_automation", p)?; + let r = range_param(e, "edit.clear_automation", p)?; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + for l in &mut tr.automation { + let hit = match which { + None => true, + Some("pan") => matches!(l.param, soundcraft_model::AutoParam::Pan(_)), + Some(_) => matches!(l.param, soundcraft_model::AutoParam::Plugin { .. }), + }; + if hit { + n += l.clear_range(r.start, r.end); + } + } + } + } + Ok(json!({"points_removed": n})) +} + +fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result { + let range = range_param(e, "edit.paste_repeat_to_fill", p)?; + let len = e.clipboard.length.max(1); + let tracks = tracks_required(e, "edit.paste_repeat_to_fill", p)?; + let cb = e.clipboard.clone(); + let s = e.session_mut(); + let mut at = range.start; + let mut n = 0; + while at < range.end && n < 10_000 { + for (i, t) in tracks.iter().enumerate() { + if let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) { + let trimmed: Vec<_> = clips + .iter() + .filter(|c| at + c.start < range.end) + .map(|c| { + let mut c = c.clone(); + if at + c.end() > range.end { + c.trim_end_to(range.end - at); + } + c + }) + .collect(); + ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false); + } + } + at += len; + n += 1; + } + Ok(json!({"repeats": n})) +} + +fn select_all(e: &mut Engine, _: &Value) -> Result { + let s = e.session_mut(); + s.edit.selected_tracks = s.tracks.iter().filter(|t| !t.hidden).map(|t| t.id).collect(); + let end = s.content_end(); + s.edit.selection = soundcraft_time::Range::new(0, end); + Ok(json!({"tracks": s.edit.selected_tracks.len(), "end": end})) +} + +fn select(e: &mut Engine, p: &Value) -> Result { + let tracks = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "edit.select", p)?) } else { None }; + let st = position_param(e, "edit.select", p, "start")?; + let en = position_param(e, "edit.select", p, "end")?; + let clips = + p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::>()); + let s = e.session_mut(); + if let Some(t) = tracks { + s.edit.selected_tracks = t; + } + match (st, en) { + (Some(a), Some(b)) => s.edit.selection = soundcraft_time::Range::new(a.max(0), b.max(0)), + (Some(a), None) => s.edit.selection = soundcraft_time::Range::point(a.max(0)), + _ => {} + } + if let Some(c) = clips { + // Selecting clips also selects their range and tracks (Grabber click). + let mut range: Option = None; + let mut trs = Vec::new(); + for id in &c { + if let Some((tid, clip)) = s.find_clip(*id) { + range = Some(range.map_or(clip.range(), |r| soundcraft_time::Range::new(r.start.min(clip.start), r.end.max(clip.end())))); + if !trs.contains(&tid) { + trs.push(tid); + } + } + } + if let Some(r) = range { + s.edit.selection = r; + s.edit.selected_tracks = trs; + } + s.edit.selected_clips = c; + } else if st.is_some() { + s.edit.selected_clips.clear(); + } + if s.edit.link_timeline_edit { + s.edit.timeline_selection = s.edit.selection; + } + Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips})) +} + +fn shift_selection(e: &mut Engine, dir: i64) -> Result { + let s = e.session_mut(); + let len = s.edit.selection.len().max(1); + s.edit.selection = s.edit.selection.shifted(dir * len); + if s.edit.selection.start < 0 { + s.edit.selection = s.edit.selection.shifted(-s.edit.selection.start); + } + Ok(json!({"selection": s.edit.selection})) +} + +fn scale_selection(e: &mut Engine, f: f64) -> Result { + let s = e.session_mut(); + let len = soundcraft_time::to_samples(s.edit.selection.len() as f64 * f); + s.edit.selection.end = s.edit.selection.start.saturating_add(len); + Ok(json!({"selection": s.edit.selection})) +} + +fn extend_tracks(e: &mut Engine, dir: i64) -> Result { + let s = e.session_mut(); + let idxs: Vec = s.edit.selected_tracks.iter().filter_map(|t| s.track_index(*t)).collect(); + let next = if dir < 0 { idxs.iter().min().and_then(|i| i.checked_sub(1)) } else { idxs.iter().max().map(|i| i + 1) }; + if let Some(t) = next.and_then(|i| s.tracks.get(i)).map(|t| t.id) { + if dir < 0 { + s.edit.selected_tracks.insert(0, t); + } else { + s.edit.selected_tracks.push(t); + } + } else if idxs.is_empty() + && let Some(t) = s.tracks.first() + { + s.edit.selected_tracks.push(t.id); + } + Ok(json!({"tracks": s.edit.selected_tracks})) +} + +fn duplicate(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.duplicate", p)?; + let range = range_param(e, "edit.duplicate", p)?; + if range.is_empty() { + return Err(bad("edit.duplicate", "selection is empty")); + } + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + let clips = ops::copy_range(s, *t, range); + n += ops::paste(s, *t, range.end, &clips, range.len(), false).len(); + if let Some(tr) = s.track_mut(*t) { + for l in &mut tr.automation { + let pts: Vec<_> = l.points.iter().filter(|p| range.contains(p.at)).copied().collect(); + for pt in pts { + l.set_point(pt.at + range.len(), pt.value); + } + } + } + } + s.edit.selection = range.shifted(range.len()); + Ok(json!({"clips": n})) +} + +fn repeat(e: &mut Engine, p: &Value) -> Result { + let count = i64_or(p, "count", 1).clamp(1, 999); + let tracks = tracks_required(e, "edit.repeat", p)?; + let range = range_param(e, "edit.repeat", p)?; + if range.is_empty() { + return Err(bad("edit.repeat", "selection is empty")); + } + let s = e.session_mut(); + for t in &tracks { + let clips = ops::copy_range(s, *t, range); + for k in 1..=count { + ops::paste(s, *t, range.start + range.len() * k, &clips, range.len(), false); + } + } + Ok(json!({"repeats": count})) +} + +fn shift(e: &mut Engine, p: &Value) -> Result { + let by = position_param(e, "edit.shift", p, "by")?.ok_or_else(|| bad("edit.shift", "`by` is required"))?; + let later = bool_or(p, "later", true); + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + let n = ops::move_clips(s, &ids, if later { by } else { -by }, None); + Ok(json!({"moved": n})) +} + +fn insert_silence(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.insert_silence", p)?; + let range = range_param(e, "edit.insert_silence", p)?; + let s = e.session_mut(); + for t in &tracks { + ops::insert_silence(s, *t, range); + if let Some(tr) = s.track_mut(*t) { + for l in &mut tr.automation { + l.shift_from(range.start, range.len()); + } + } + } + Ok(json!({"inserted": range.len()})) +} + +fn separate(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.separate", p)?; + let points: Vec = if let Some(at) = position_param(e, "edit.separate", p, "at")? { + vec![at] + } else { + let r = range_param(e, "edit.separate", p)?; + if r.is_empty() { vec![r.start] } else { vec![r.start, r.end] } + }; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + for at in &points { + n += ops::separate_at(s, *t, *at); + } + } + Ok(json!({"separated": n})) +} + +fn separate_on_grid(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.separate_on_grid", p)?; + let r = range_param(e, "edit.separate_on_grid", p)?; + let s = e.session_mut(); + let lines = s.edit.grid.lines(r.start, r.end, s.sample_rate, &s.tempo, s.frame_rate, 10_000); + let mut n = 0; + for t in &tracks { + for at in &lines { + n += ops::separate_at(s, *t, *at); + } + } + Ok(json!({"separated": n})) +} + +fn separate_at_transients(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.separate_at_transients", p)?; + let r = range_param(e, "edit.separate_at_transients", p)?; + let sens = f32_or(p, "sensitivity", 0.5).clamp(0.0, 1.0); + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + let hits = crate::io::transients_in(s, *t, r, sens); + for at in hits { + n += ops::separate_at(s, *t, at); + } + } + Ok(json!({"separated": n})) +} + +fn heal(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.heal", p)?; + let mut r = range_param(e, "edit.heal", p)?; + if r.is_empty() { + r = soundcraft_time::Range::new(r.start - 1, r.start + 1); + } + let s = e.session_mut(); + let n: usize = tracks.iter().map(|t| ops::heal(s, *t, r)).sum(); + Ok(json!({"healed": n})) +} + +fn trim_to_selection(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.trim_to_selection", p)?; + let r = range_param(e, "edit.trim_to_selection", p)?; + let s = e.session_mut(); + let n: usize = tracks.iter().map(|t| ops::trim_to(s, *t, r)).sum(); + Ok(json!({"trimmed": n})) +} + +fn trim_edge(e: &mut Engine, p: &Value, start: bool) -> Result { + let tracks = tracks_required(e, "edit.trim", p)?; + let at = position_param(e, "edit.trim", p, "at")?.unwrap_or(e.session().edit.selection.start); + let s = e.session_mut(); + let n: usize = tracks.iter().map(|t| if start { ops::trim_start_to(s, *t, at) } else { ops::trim_end_to(s, *t, at) }).sum(); + Ok(json!({"trimmed": n})) +} + +fn trim_to_file(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + let mut n = 0; + for id in ids { + let frames = + s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX)); + if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) { + let off = c.source_offset(); + let new_start = c.start - off; + c.trim_start_to(new_start); + c.trim_end_to(new_start + frames); + n += 1; + } + } + Ok(json!({"trimmed": n})) +} + +fn trim_to_fill(e: &mut Engine, p: &Value) -> Result { + let r = range_param(e, "edit.trim_to_fill_selection", p)?; + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + let mut n = 0; + for id in ids { + let frames = + s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX)); + if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) { + let file_start = c.start - c.source_offset(); + c.trim_start_to(r.start.max(file_start)); + c.trim_end_to(r.end.min(file_start + frames)); + n += 1; + } + } + Ok(json!({"trimmed": n})) +} + +fn mute_clips(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + let any_unmuted = ids.iter().any(|id| s.find_clip(*id).is_some_and(|(_, c)| !c.muted)); + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.muted = any_unmuted; + } + } + Ok(json!({"muted": any_unmuted, "clips": ids.len()})) +} + +fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.copy_to_new_playlist", p)?; + let r = range_param(e, "edit.copy_to_new_playlist", p)?; + let s = e.session_mut(); + for t in &tracks { + let clips = ops::copy_range(s, *t, r); + let mut fresh = Vec::new(); + for mut c in clips { + c.id = s.new_clip_id(); + c.start += r.start; + fresh.push(c); + } + if let Some(tr) = s.track_mut(*t) { + let name = format!("{}.{:02}", tr.name, tr.playlists.len() + 1); + tr.playlists.push(soundcraft_model::Playlist { name, clips: fresh }); + tr.active_playlist = tr.playlists.len() - 1; + } + } + Ok(json!({"tracks": tracks.len()})) +} + +fn strip_silence(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.strip_silence", p)?; + let r = range_param(e, "edit.strip_silence", 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); + let (min_len, pre, post) = (ms("min_length_ms", 50.0), ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0)); + let s = e.session_mut(); + let mut kept = 0; + for t in &tracks { + kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post); + } + Ok(json!({"clips": kept})) +} + +fn fades_create(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.fades_create", p)?; + let r = range_param(e, "edit.fades_create", p)?; + let shape = str_param(p, "shape") + .map_or(Some(FadeShape::EqualPower), FadeShape::from_id) + .ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?; + let s = e.session_mut(); + let n: usize = tracks.iter().map(|t| ops::create_fades(s, *t, r, shape)).sum(); + if n == 0 { + return Err(bad("edit.fades_create", "the selection must cover a clip start, end or the boundary between two clips")); + } + Ok(json!({"fades": n})) +} + +fn fades_delete(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.fades_delete", p)?; + let r = range_param(e, "edit.fades_delete", p)?; + let s = e.session_mut(); + let n: usize = tracks.iter().map(|t| ops::delete_fades(s, *t, r)).sum(); + Ok(json!({"deleted": n})) +} + +fn fade_to(e: &mut Engine, p: &Value, to_start: bool) -> Result { + let tracks = tracks_required(e, "edit.fade_to", p)?; + let at = e.session().edit.selection.start; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + let range = s.track(*t).and_then(|tr| { + tr.clips() + .iter() + .find(|c| c.range().contains(at)) + .map(|c| if to_start { soundcraft_time::Range::new(c.start, at) } else { soundcraft_time::Range::new(at, c.end()) }) + }); + if let Some(r) = range { + n += ops::create_fades(s, *t, r, FadeShape::EqualPower); + } + } + Ok(json!({"fades": n})) +} + +fn nudge(e: &mut Engine, p: &Value) -> Result { + let dir = i64_or(p, "direction", 1).signum(); + let count = i64_or(p, "count", 1).clamp(1, 1000); + let s = e.session(); + let step = s.edit.nudge.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir }; + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, None) }; + s.edit.selection = s.edit.selection.shifted(step); + if s.edit.selection.start < 0 { + s.edit.selection = s.edit.selection.shifted(-s.edit.selection.start); + } + Ok(json!({"moved": n, "by": step})) +} + +fn move_clips(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + if ids.is_empty() { + return Err(bad("edit.move_clips", "no clips")); + } + let to_track = track_param(e, "edit.move_clips", p, "track")?; + let first = ids.iter().filter_map(|id| e.session().find_clip(*id).map(|(_, c)| c.start)).min().unwrap_or(0); + let delta = match (position_param(e, "edit.move_clips", p, "by")?, position_param(e, "edit.move_clips", p, "to")?) { + (Some(by), _) => by, + (None, Some(to)) => to - first, + _ => 0, + }; + // Grid mode snaps the destination. + let s = e.session(); + let delta = if s.edit.edit_mode.is_grid() && p.get("to").is_some() { + s.edit.grid.snap(first + delta, s.sample_rate, &s.tempo, s.frame_rate) - first + } else { + delta + }; + let s = e.session_mut(); + let n = ops::move_clips(s, &ids, delta, to_track); + s.edit.selected_clips = ids; + Ok(json!({"moved": n, "by": delta})) +} + +fn consolidate(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.consolidate", p)?; + let r = range_param(e, "edit.consolidate", p)?; + if r.is_empty() { + return Err(bad("edit.consolidate", "selection is empty")); + } + let mut n = 0; + for t in tracks { + if crate::io::consolidate(e, t, r)? { + n += 1; + } + } + Ok(json!({"consolidated": n})) +} diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs new file mode 100644 index 00000000..bfbb6df8 --- /dev/null +++ b/crates/engine/src/cmd/event.rs @@ -0,0 +1,361 @@ +//! Event menu: time/tempo operations, MIDI operations, markers. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp}; +use soundcraft_model::{ClipContent, MarkerKind}; +use soundcraft_time::TICKS_PER_QUARTER; + +pub fn specs() -> Vec { + vec![ + cmd!("event.tempo", "Tempo Change", [], None, "{bpm, at?: position (default session start)}", always, tempo), + cmd!("event.tempo_remove", "Remove Tempo Change", [], None, "{at}", always, |e, p| { + let at = position_param(e, "event.tempo_remove", p, "at")?.unwrap_or(0); + let s = e.session_mut(); + let tick = s.tempo.samples_to_ticks(at, s.sample_rate); + Ok(json!({"removed": s.tempo.remove_tempo(tick)})) + }), + cmd!("event.meter", "Change Meter...", ["Event", "Time Operations"], None, "{numerator, denominator, at?: position}", always, meter), + cmd!("event.tempo_scale", "Scale...", ["Event", "Tempo Operations"], None, "{factor}", always, |e, p| { + let f = f64_or(p, "factor", 1.0); + e.session_mut().tempo.scale_tempos(f).map_err(|err| bad("event.tempo_scale", err.to_string()))?; + Ok(json!({})) + }), + cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, tempo), + cmd!("event.insert_time", "Insert Time...", ["Event", "Time Operations"], None, "{start?, length | end}", has_tracks, insert_time), + cmd!("event.cut_time", "Cut Time...", ["Event", "Time Operations"], None, "{start?, end?}", has_tracks, cut_time), + cmd!( + "event.quantize", + "Quantize...", + ["Event", "MIDI Operations"], + Some("Alt+0"), + "{grid?: 1/16 | ticks, strength?: 100, swing?: 0, clips?}", + has_selection, + quantize + ), + cmd!("event.transpose", "Transpose...", ["Event", "MIDI Operations"], Some("Alt+T"), "{semitones, clips?}", has_selection, |e, p| { + let st = i64_or(p, "semitones", 0).clamp(-127, 127) as i32; + midi_op(e, p, move |n| soundcraft_midi::ops::transpose(n, st)) + }), + cmd!( + "event.change_velocity", + "Change Velocity...", + ["Event", "MIDI Operations"], + None, + "{set?|add?|scale?|min?,max?, clips?}", + has_selection, + velocity + ), + cmd!( + "event.change_duration", + "Change Duration...", + ["Event", "MIDI Operations"], + Some("Alt+P"), + "{set?|add?|scale?|legato?: gap, clips?}", + has_selection, + duration + ), + cmd!("event.remove_duplicate_notes", "Remove Duplicate Notes", ["Event"], None, "{clips?}", has_selection, |e, p| { + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + let mut n = 0; + for id in ids { + if let Some(c) = s.find_clip_mut(id) + && let ClipContent::Midi { sequence } = &mut c.content + { + n += sequence.remove_duplicates() + } + } + Ok(json!({"removed": n})) + }), + cmd!("event.add_key_change", "Add Key Change...", ["Event"], None, "{key: 'C major', at?}", always, |e, p| { + let key = str_param(p, "key").unwrap_or("C major").to_string(); + let at = position_param(e, "event.add_key_change", p, "at")?.unwrap_or(0); + let s = e.session_mut(); + s.key_signatures.retain(|(t, _)| *t != at); + s.key_signatures.push((at, key)); + s.key_signatures.sort_by_key(|k| k.0); + Ok(json!({})) + }), + cmd!("event.renumber_bars", "Renumber Bars...", ["Event"], None, "{}", always, |_, _| Ok(json!({"note": "bars start at 1"}))), + cmd!("event.all_notes_off", "All MIDI Notes Off", ["Event"], Some("Cmd+Shift+."), "{}", always, |e, _| { + e.transport_requests.push(crate::TransportRequest::AllNotesOff); + Ok(json!({})) + }), + cmd!("event.midi_note", "Add MIDI Note", [], None, "{clip, pitch, start_ticks, length_ticks, velocity?: 100}", has_selection, add_note), + cmd!( + "markers.add", + "New Memory Location", + [], + Some("Enter"), + "{name?, at?, start?, end?, kind?: marker|selection|none, ruler?: 1..5}", + always, + add_marker + ), + cmd!("markers.delete", "Delete Memory Location", [], None, "{number | id}", always, delete_marker), + cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?}", always, edit_marker), + cmd!(noundo "markers.recall", "Recall Memory Location", [], Some("Period + n + Period"), "{number}", always, recall_marker), + cmd!(noundo "markers.next", "Go to Next Marker", [], None, "{}", always, |e, _| step_marker(e, 1)), + cmd!(noundo "markers.previous", "Go to Previous Marker", [], None, "{}", always, |e, _| step_marker(e, -1)), + ] +} + +fn tempo(e: &mut Engine, p: &Value) -> Result { + let bpm = f64_or(p, "bpm", f64::NAN); + let at = position_param(e, "event.tempo", p, "at")?.or(position_param(e, "event.tempo", p, "start")?).unwrap_or(0); + let s = e.session_mut(); + let tick = s.tempo.samples_to_ticks(at, s.sample_rate); + s.tempo.set_tempo(tick, bpm).map_err(|err| bad("event.tempo", err.to_string()))?; + Ok(json!({"tick": tick, "bpm": bpm})) +} + +fn meter(e: &mut Engine, p: &Value) -> Result { + let num = i64_or(p, "numerator", 4).clamp(1, 64) as u32; + let den = i64_or(p, "denominator", 4).clamp(1, 64) as u32; + let at = position_param(e, "event.meter", p, "at")?.unwrap_or(0); + let s = e.session_mut(); + let tick = s.tempo.samples_to_ticks(at, s.sample_rate); + s.tempo.set_meter(tick, num, den).map_err(|err| bad("event.meter", err.to_string()))?; + Ok(json!({"meter": format!("{num}/{den}")})) +} + +fn insert_time(e: &mut Engine, p: &Value) -> Result { + let r = range_param(e, "event.insert_time", p)?; + if r.is_empty() { + return Err(bad("event.insert_time", "give `start` and `length` or `end`")); + } + let s = e.session_mut(); + let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect(); + for t in ids { + crate::edit::insert_silence(s, t, r); + if let Some(tr) = s.track_mut(t) { + for l in &mut tr.automation { + l.shift_from(r.start, r.len()); + } + } + } + for m in &mut s.markers { + if m.start >= r.start { + m.start += r.len(); + m.end += r.len(); + } + } + Ok(json!({"inserted": r.len()})) +} + +fn cut_time(e: &mut Engine, p: &Value) -> Result { + let r = range_param(e, "event.cut_time", p)?; + if r.is_empty() { + return Err(bad("event.cut_time", "selection is empty")); + } + let s = e.session_mut(); + let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect(); + for t in ids { + crate::edit::clear_range(s, t, r, true); + if let Some(tr) = s.track_mut(t) { + for l in &mut tr.automation { + l.clear_range(r.start, r.end); + l.shift_from(r.end, -r.len()); + } + } + } + s.markers.retain(|m| !r.contains(m.start)); + for m in &mut s.markers { + if m.start >= r.end { + m.start -= r.len(); + m.end -= r.len(); + } + } + Ok(json!({"removed": r.len()})) +} + +fn midi_op(e: &mut Engine, p: &Value, f: impl Fn(&mut [soundcraft_midi::Note])) -> Result { + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + let mut n = 0; + for id in ids { + if let Some(c) = s.find_clip_mut(id) + && let ClipContent::Midi { sequence } = &mut c.content + { + f(&mut sequence.notes); + sequence.sort(); + n += sequence.notes.len(); + } + } + Ok(json!({"notes": n})) +} + +fn grid_ticks(p: &Value) -> i64 { + match p.get("grid") { + Some(Value::Number(n)) => n.as_i64().unwrap_or(240).clamp(1, TICKS_PER_QUARTER * 16), + Some(Value::String(s)) => match s.trim() { + "1/1" | "1" | "bar" => TICKS_PER_QUARTER * 4, + "1/2" => TICKS_PER_QUARTER * 2, + "1/4" => TICKS_PER_QUARTER, + "1/8" => TICKS_PER_QUARTER / 2, + "1/8t" => TICKS_PER_QUARTER / 3, + "1/16t" => TICKS_PER_QUARTER / 6, + "1/32" => TICKS_PER_QUARTER / 8, + "1/64" => TICKS_PER_QUARTER / 16, + _ => TICKS_PER_QUARTER / 4, + }, + _ => TICKS_PER_QUARTER / 4, + } +} + +fn quantize(e: &mut Engine, p: &Value) -> Result { + let opts = QuantizeOptions { + grid_ticks: grid_ticks(p), + strength: (f32_or(p, "strength", 100.0) / 100.0).clamp(0.0, 1.0), + swing: (f32_or(p, "swing", 0.0) / 100.0).clamp(0.0, 1.0), + ..QuantizeOptions::default() + }; + midi_op(e, p, move |n| soundcraft_midi::ops::quantize(n, &opts)) +} + +fn velocity(e: &mut Engine, p: &Value) -> Result { + let op = if let Some(v) = p.get("set").and_then(Value::as_u64) { + VelocityOp::Set(v.min(127) as u8) + } else if let Some(v) = p.get("add").and_then(Value::as_i64) { + VelocityOp::Add(v.clamp(-127, 127) as i32) + } else if let Some(v) = p.get("scale").and_then(Value::as_f64) { + VelocityOp::Scale((v as f32).clamp(0.0, 10.0)) + } else { + VelocityOp::Limit { min: i64_or(p, "min", 1).clamp(1, 127) as u8, max: i64_or(p, "max", 127).clamp(1, 127) as u8 } + }; + midi_op(e, p, move |n| soundcraft_midi::ops::change_velocity(n, op)) +} + +fn duration(e: &mut Engine, p: &Value) -> Result { + let op = if let Some(v) = p.get("set").and_then(Value::as_i64) { + DurationOp::Set(v.max(1)) + } else if let Some(v) = p.get("add").and_then(Value::as_i64) { + DurationOp::Add(v) + } else if let Some(v) = p.get("scale").and_then(Value::as_f64) { + DurationOp::Scale((v as f32).clamp(0.01, 100.0)) + } else { + DurationOp::Legato { gap: i64_or(p, "legato", 0).max(0) } + }; + midi_op(e, p, move |n| soundcraft_midi::ops::change_duration(n, op)) +} + +fn add_note(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + let id = *ids.first().ok_or_else(|| bad("event.midi_note", "`clip` required"))?; + let note = soundcraft_midi::Note { + pitch: i64_or(p, "pitch", 60).clamp(0, 127) as u8, + velocity: i64_or(p, "velocity", 100).clamp(1, 127) as u8, + release_velocity: 64, + channel: i64_or(p, "channel", 0).clamp(0, 15) as u8, + start: i64_or(p, "start_ticks", 0).max(0), + length: i64_or(p, "length_ticks", TICKS_PER_QUARTER).max(1), + }; + let s = e.session_mut(); + match s.find_clip_mut(id).map(|c| &mut c.content) { + Some(ClipContent::Midi { sequence }) => { + sequence.notes.push(note); + sequence.sort(); + Ok(json!({"notes": sequence.notes.len()})) + } + _ => Err(bad("event.midi_note", "not a MIDI clip")), + } +} + +fn add_marker(e: &mut Engine, p: &Value) -> Result { + let sel = e.session().edit.selection; + let at = position_param(e, "markers.add", p, "at")?; + let st = position_param(e, "markers.add", p, "start")?; + let en = position_param(e, "markers.add", p, "end")?; + let kind = match str_param(p, "kind") { + Some("selection") => MarkerKind::Selection, + Some("none") => MarkerKind::None, + _ => MarkerKind::Marker, + }; + let (a, b) = match (at, st, en) { + (Some(a), _, _) => (a, a), + (None, Some(a), Some(b)) => (a, b), + _ if kind == MarkerKind::Selection => (sel.start, sel.end), + _ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start), + }; + let s = e.session_mut(); + let n = s.markers.len() + 1; + let name = str_param(p, "name").map_or_else(|| format!("Marker {n}"), str::to_string); + let id = s.add_marker(&name, kind, a.max(0), b.max(a).max(0)); + if let Some(m) = s.markers.iter_mut().find(|m| m.id == id) { + m.ruler = i64_or(p, "ruler", 1).clamp(1, 5) as u8; + } + let number = s.marker(id).map_or(0, |m| m.number); + Ok(json!({"id": id, "number": number})) +} + +fn marker_index(e: &Engine, p: &Value, cmd: &str) -> Result { + let s = e.session(); + let idx = if let Some(n) = p.get("number").and_then(Value::as_u64) { + s.markers.iter().position(|m| u64::from(m.number) == n) + } else if let Some(n) = p.get("id").and_then(Value::as_u64) { + s.markers.iter().position(|m| m.id.0 == n) + } else if let Some(name) = str_param(p, "name") { + s.markers.iter().position(|m| m.name == name) + } else { + None + }; + idx.ok_or_else(|| bad(cmd, "unknown memory location (give `number`, `id` or `name`)")) +} + +fn delete_marker(e: &mut Engine, p: &Value) -> Result { + let i = marker_index(e, p, "markers.delete")?; + e.session_mut().markers.remove(i); + Ok(json!({})) +} + +fn edit_marker(e: &mut Engine, p: &Value) -> Result { + let i = marker_index(e, p, "markers.edit")?; + let at = position_param(e, "markers.edit", p, "at")?; + let s = e.session_mut(); + let Some(m) = s.markers.get_mut(i) else { return Err(bad("markers.edit", "gone")) }; + if let Some(n) = p.get("rename").and_then(Value::as_str) { + m.name = n.to_string(); + } + if let Some(c) = str_param(p, "comments") { + m.comments = c.to_string(); + } + if let Some(a) = at { + let len = m.end - m.start; + m.start = a.max(0); + m.end = m.start + len; + } + s.markers.sort_by_key(|m| m.start); + Ok(json!({})) +} + +fn recall_marker(e: &mut Engine, p: &Value) -> Result { + let i = marker_index(e, p, "markers.recall")?; + let s = e.session(); + let Some(m) = s.markers.get(i).cloned() else { return Err(bad("markers.recall", "gone")) }; + let s = e.session_mut(); + match m.kind { + MarkerKind::Marker => s.edit.selection = soundcraft_time::Range::point(m.start), + MarkerKind::Selection => s.edit.selection = soundcraft_time::Range::new(m.start, m.end), + MarkerKind::None => {} + } + s.edit.playhead = m.start; + e.transport_requests.push(crate::TransportRequest::Locate(m.start)); + Ok(json!({"at": m.start})) +} + +fn step_marker(e: &mut Engine, dir: i64) -> Result { + let cur = e.session().edit.selection.start; + let s = e.session(); + let target = if dir > 0 { + s.markers.iter().map(|m| m.start).filter(|t| *t > cur).min() + } else { + s.markers.iter().map(|m| m.start).filter(|t| *t < cur).max() + }; + let Some(t) = target else { return Ok(json!({"moved": false})) }; + let s = e.session_mut(); + s.edit.selection = soundcraft_time::Range::point(t); + s.edit.playhead = t; + e.transport_requests.push(crate::TransportRequest::Locate(t)); + Ok(json!({"at": t})) +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs new file mode 100644 index 00000000..d6bdcd2f --- /dev/null +++ b/crates/engine/src/cmd/file.rs @@ -0,0 +1,158 @@ +//! File menu commands. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::Session; +use soundcraft_time::SampleRate; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "session.new", "New...", ["File"], Some("Cmd+N"), "{name?: 'Untitled', sample_rate?: 48000, bit_depth?: 24, template?: blank|demo}", always, new_session), + cmd!(noundo "session.open", "Open Session...", ["File"], Some("Cmd+O"), "{path}", always, |e, p| { + let path = str_param(p, "path").ok_or_else(|| bad("session.open", "`path` required"))?.to_string(); + crate::io::open_session(e, &path)?; + Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len()})) + }), + cmd!(noundo "session.close", "Close Session", ["File"], Some("Cmd+Shift+W"), "{}", always, |e, _| { e.replace_session(Session::default()); e.path = None; Ok(json!({})) }), + cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?}", always, |e, p| { + let path = str_param(p, "path").map(str::to_string).or_else(|| e.path.clone()).ok_or_else(|| bad("session.save", "no path yet: pass `path` (Save As)"))?; + let written = crate::io::save_session(e, &path)?; + Ok(json!({"path": path, "audio_files_written": written})) + }), + cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path}", always, |e, p| { + let path = str_param(p, "path").ok_or_else(|| bad("session.save_as", "`path` required"))?.to_string(); + let written = crate::io::save_session(e, &path)?; + Ok(json!({"path": path, "audio_files_written": written})) + }), + cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| { + let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string(); + let keep = e.path.clone(); + let written = crate::io::save_session(e, &path)?; + e.path = keep; + Ok(json!({"path": path, "audio_files_written": written})) + }), + cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| { + let path = str_param(p, "path").ok_or_else(|| bad("session.save_template", "`path` required"))?.to_string(); + let mut s = e.session().clone(); + for t in &mut s.tracks { for pl in &mut t.playlists { pl.clips.clear() } } + s.sources.clear(); + let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + Ok(json!({"path": path})) + }), + cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| { + let path = e.path.clone().ok_or_else(|| bad("session.revert", "the session has not been saved"))?; + crate::io::open_session(e, &path)?; + Ok(json!({})) + }), + cmd!( + "file.import_audio", + "Audio...", + ["File", "Import"], + Some("Cmd+Shift+I"), + "{path | paths: [..], track?: target track, at?: position, new_tracks?: true}", + always, + import_audio + ), + cmd!("file.import_midi", "MIDI...", ["File", "Import"], Some("Cmd+Alt+I"), "{path, at?}", always, |e, p| { + let path = str_param(p, "path").ok_or_else(|| bad("file.import_midi", "`path` required"))?.to_string(); + let at = position_param(e, "file.import_midi", p, "at")?.unwrap_or(0); + let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let tracks = crate::io::import_midi_bytes(e, &bytes, at, bool_or(p, "tempo_map", true))?; + Ok(json!({"tracks": tracks})) + }), + cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true}", always, bounce), + cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| { + let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string(); + let bytes = crate::io::export_midi(e)?; + std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + Ok(json!({"path": path, "bytes": bytes.len()})) + }), + cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| { + let text = crate::inspect::session_text(e); + if let Some(path) = str_param(p, "path") { + std::fs::write(path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + } + Ok(json!({"text": text})) + }), + cmd!(noundo "file.export_clips", "Export Clips as Files...", [], None, "{dir, clips?, format?: wav}", always, |e, p| { + let dir = str_param(p, "dir").ok_or_else(|| bad("file.export_clips", "`dir` required"))?.to_string(); + let ids = clip_ids_param(e, p); + let n = crate::io::export_clips(e, &ids, &dir)?; + Ok(json!({"written": n})) + }), + cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?}", has_selection, |e, p| { + let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string(); + let keep = tracks_required(e, "file.export_selected_tracks_as_session", p)?; + let mut s = e.session().clone(); + s.tracks.retain(|t| keep.contains(&t.id) || t.kind == soundcraft_model::TrackKind::Master); + let mut tmp = crate::Engine::new(s); + crate::io::save_session(&mut tmp, &path)?; + Ok(json!({"path": path})) + }), + cmd!(query "file.get_info", "Get Info...", ["File"], None, "{}", always, |e, _| Ok(crate::inspect::session(e, false))), + cmd!(noundo "app.quit", "Quit", [], Some("Cmd+Q"), "{}", always, |_, _| Ok(json!({"quit": true}))), + ] +} + +fn new_session(e: &mut Engine, p: &Value) -> Result { + let name = str_param(p, "name").unwrap_or("Untitled").to_string(); + let sr = + SampleRate::new(i64_or(p, "sample_rate", 48_000).clamp(0, i64::from(u32::MAX)) as u32).map_err(|err| bad("session.new", err.to_string()))?; + let mut s = if str_param(p, "template") == Some("demo") { crate::demo::demo_session() } else { Session::new(name.clone(), sr) }; + s.name = name; + if let Some(b) = p.get("bit_depth") { + s.bit_depth = match b.to_string().trim_matches('"') { + "16" => soundcraft_model::BitDepthSetting::Int16, + "32" | "32f" => soundcraft_model::BitDepthSetting::Float32, + _ => soundcraft_model::BitDepthSetting::Int24, + }; + } + e.replace_session(s); + e.path = None; + Ok(json!({"name": e.session().name, "sample_rate": e.session().sample_rate.hz()})) +} + +fn import_audio(e: &mut Engine, p: &Value) -> Result { + let mut paths: Vec = + p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + if let Some(one) = str_param(p, "path") { + paths.push(one.to_string()); + } + if paths.is_empty() { + return Err(bad("file.import_audio", "`path` or `paths` required")); + } + let at = position_param(e, "file.import_audio", p, "at")?.unwrap_or(0).max(0); + let target = track_param(e, "file.import_audio", p, "track")?; + let mut out = Vec::new(); + for path in &paths { + let bytes = std::fs::read(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string(); + let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?; + out.push(r); + } + Ok(json!({"imported": out})) +} + +fn bounce(e: &mut Engine, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string(); + let r = range_param(e, "file.bounce_mix", p)?; + let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; + let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); + let format = match str_param(p, "format").unwrap_or(ext.as_str()) { + "aif" | "aiff" => soundcraft_audio_io::FileFormat::Aiff, + "flac" => soundcraft_audio_io::FileFormat::Flac, + _ => soundcraft_audio_io::FileFormat::Wav, + }; + let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() { + Some("16") => soundcraft_audio_io::BitDepth::Int16, + Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32, + _ => soundcraft_audio_io::BitDepth::Int24, + }; + let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }; + let normalize = bool_or(p, "normalize", false); + let (bytes, stats) = crate::io::bounce_bytes(e, r, &opts, normalize)?; + std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + Ok(json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1})) +} diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs new file mode 100644 index 00000000..da811368 --- /dev/null +++ b/crates/engine/src/cmd/mix.rs @@ -0,0 +1,575 @@ +//! Mixer, insert, send and automation commands. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId}; + +pub fn specs() -> Vec { + vec![ + cmd!("mix.volume", "Set Volume", [], None, "{tracks?, db: -144..12 | delta_db}", has_selection, volume), + cmd!("mix.pan", "Set Pan", [], None, "{tracks?, pan: -1..1, index?: 0|1}", has_selection, pan), + cmd!("mix.mute", "Mute", [], Some("Shift+M"), "{tracks?, value?: bool}", has_selection, |e, p| toggle( + e, + p, + "mix.mute", + |s, t, v| set_grouped(s, t, "mute", |tr| tr.mixer.mute = v), + |tr| tr.mixer.mute + )), + cmd!("mix.solo", "Solo", [], Some("Shift+S"), "{tracks?, value?: bool, exclusive?: bool}", has_selection, solo), + cmd!("mix.solo_safe", "Solo Safe", [], None, "{tracks?, value?: bool}", has_selection, |e, p| toggle( + e, + p, + "mix.solo_safe", + |s, t, v| { + if let Some(tr) = s.track_mut(t) { + tr.mixer.solo_safe = v + } + }, + |tr| tr.mixer.solo_safe + )), + cmd!("mix.clear_solos", "Clear All Solos", [], None, "{}", always, |e, _| { + for t in &mut e.session_mut().tracks { + t.mixer.solo = false + } + Ok(json!({})) + }), + cmd!("mix.clear_mutes", "Clear All Mutes", [], None, "{}", always, |e, _| { + for t in &mut e.session_mut().tracks { + t.mixer.mute = false + } + Ok(json!({})) + }), + cmd!("mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle( + e, + p, + "mix.record_arm", + |s, t, v| set_grouped(s, t, "record", |tr| if tr.kind.has_playlist() { + tr.mixer.record_arm = v + }), + |tr| tr.mixer.record_arm + )), + cmd!("mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle( + e, + p, + "mix.input_monitor", + |s, t, v| { + if let Some(tr) = s.track_mut(t) { + tr.mixer.input_monitor = v + } + }, + |tr| tr.mixer.input_monitor + )), + cmd!("mix.phase_invert", "Phase Invert", [], None, "{tracks?, value?: bool}", has_selection, |e, p| toggle( + e, + p, + "mix.phase_invert", + |s, t, v| { + if let Some(tr) = s.track_mut(t) { + tr.mixer.phase_invert = v + } + }, + |tr| tr.mixer.phase_invert + )), + cmd!("mix.trim", "Input Trim", [], None, "{tracks?, db}", has_selection, |e, p| { + let tracks = tracks_required(e, "mix.trim", p)?; + let db = f32_or(p, "db", 0.0).clamp(-144.0, 24.0); + let s = e.session_mut(); + for t in tracks { + if let Some(tr) = s.track_mut(t) { + tr.mixer.trim_db = db + } + } + Ok(json!({"db": db})) + }), + cmd!( + "mix.automation_mode", + "Automation Mode", + [], + None, + "{tracks?, mode: off|read|touch|latch|touch/latch|write|trim}", + has_selection, + automation_mode + ), + cmd!("mix.insert", "Insert Plugin", [], None, "{track?, slot?: 0..9 | a..j, plugin: id, params?: {id: value}}", has_selection, insert), + cmd!("mix.insert_remove", "Remove Insert", [], None, "{track?, slot}", has_selection, insert_remove), + cmd!("mix.insert_bypass", "Bypass Insert", [], None, "{track?, slot, value?: bool}", has_selection, insert_bypass), + cmd!("mix.insert_param", "Set Plugin Parameter", [], None, "{track?, slot, param, value}", has_selection, insert_param), + cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move), + cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0, pre_fader?: false}", has_selection, send), + cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| { + let (t, slot) = track_slot(e, p, "mix.send_remove", SEND_SLOTS)?; + if let Some(tr) = e.session_mut().track_mut(t) + && let Some(s) = tr.mixer.sends.get_mut(slot) + { + *s = None + } + Ok(json!({})) + }), + cmd!("mix.send_level", "Send Level", [], None, "{track?, slot, db?, pan?, mute?, pre_fader?}", has_selection, send_level), + cmd!("mix.new_bus", "New Bus", [], None, "{name, format?: stereo}", always, |e, p| { + let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad("mix.new_bus", "`name` required"))?.to_string(); + let fmt = str_param(p, "format").and_then(soundcraft_model::ChannelFormat::from_id).unwrap_or(soundcraft_model::ChannelFormat::Stereo); + let id = e.session_mut().add_bus(&name, fmt); + Ok(json!({"bus": id})) + }), + cmd!( + "automation.set_point", + "Add Automation Breakpoint", + [], + None, + "{track?, param: volume|pan|mute|send_a_level|plugin:a:, at, value}", + has_selection, + set_point + ), + cmd!("automation.write_range", "Write Automation", [], None, "{track?, param, start?, end?, value}", has_selection, write_range), + cmd!("automation.clear", "Clear Automation", [], None, "{track?, param?, start?, end?}", has_selection, clear_lane), + cmd!("automation.thin", "Thin", ["Edit", "Automation"], Some("Cmd+Alt+T"), "{tracks?, tolerance?: 0.1}", has_selection, |e, p| thin( + e, p, false + )), + cmd!("automation.thin_all", "Thin All", ["Edit", "Automation"], None, "{tolerance?: 0.1}", has_tracks, |e, p| thin(e, p, true)), + cmd!("automation.write_to_current", "Write to Current", ["Edit", "Automation"], Some("Cmd+/"), "{tracks?}", has_range, |e, p| write_current( + e, p, false + )), + cmd!("automation.write_to_all", "Write to All Enabled", ["Edit", "Automation"], Some("Cmd+Alt+/"), "{tracks?}", has_range, |e, p| { + write_current(e, p, true) + }), + cmd!( + "automation.volume_to_clip_gain", + "Convert Volume Automation to Clip Gain", + ["Edit", "Automation"], + None, + "{tracks?}", + has_range, + vol_to_clip_gain + ), + cmd!( + "automation.clip_gain_to_volume", + "Convert Clip Gain to Volume Automation", + ["Edit", "Automation"], + None, + "{tracks?}", + has_range, + clip_gain_to_vol + ), + cmd!( + "automation.copy_to_send", + "Copy to Send...", + ["Edit", "Automation"], + Some("Cmd+Alt+H"), + "{tracks?, send: slot}", + has_selection, + copy_to_send + ), + ] +} + +/// Apply a change to `t` and its active mix-group siblings. +fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) { + let mut targets = vec![t]; + for g in &s.groups { + if g.active && g.mix && g.members.contains(&t) && g.attributes.iter().any(|a| a == attr) { + for m in &g.members { + if !targets.contains(m) { + targets.push(*m); + } + } + } + } + for id in targets { + if let Some(tr) = s.track_mut(id) { + f(tr); + } + } +} + +fn toggle(e: &mut Engine, p: &Value, cmd: &str, set: fn(&mut Session, TrackId, bool), get: fn(&soundcraft_model::Track) -> bool) -> Result { + let tracks = tracks_required(e, cmd, p)?; + let s = e.session_mut(); + let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(get))); + for t in &tracks { + set(s, *t, v); + } + Ok(json!({"value": v})) +} + +fn volume(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "mix.volume", p)?; + let abs = p.get("db").and_then(Value::as_f64); + let delta = p.get("delta_db").and_then(Value::as_f64); + if abs.is_none() && delta.is_none() { + return Err(bad("mix.volume", "`db` or `delta_db` required")); + } + let s = e.session_mut(); + for t in &tracks { + let cur = s.track(*t).map_or(0.0, |tr| tr.mixer.volume_db); + let new = match (abs, delta) { + (Some(a), _) => a as f32, + (None, Some(d)) => cur + d as f32, + _ => cur, + }; + let new = if new.is_finite() { new.clamp(-144.0, 12.0) } else { cur }; + let d = new - cur; + set_grouped(s, *t, "volume", |tr| tr.mixer.volume_db = if tr.id == *t { new } else { (tr.mixer.volume_db + d).clamp(-144.0, 12.0) }); + } + Ok(json!({"tracks": tracks.len()})) +} + +fn pan(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "mix.pan", p)?; + let v = f32_or(p, "pan", 0.0).clamp(-1.0, 1.0); + let idx = i64_or(p, "index", 0).clamp(0, 1) as usize; + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) + && let Some(slot) = tr.mixer.pan.get_mut(idx) + { + *slot = v; + } + } + Ok(json!({"pan": v})) +} + +fn solo(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "mix.solo", p)?; + let exclusive = bool_or(p, "exclusive", e.session().edit.solo_mode.contains("xor")); + let s = e.session_mut(); + let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(|tr| tr.mixer.solo))); + if exclusive && v { + for tr in &mut s.tracks { + tr.mixer.solo = false; + } + } + for t in &tracks { + set_grouped(s, *t, "solo", |tr| tr.mixer.solo = v); + } + Ok(json!({"value": v})) +} + +fn automation_mode(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "mix.automation_mode", p)?; + let m = str_param(p, "mode").and_then(AutomationMode::from_id).ok_or_else(|| bad("mix.automation_mode", "unknown mode"))?; + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + tr.mixer.automation_mode = m; + } + } + Ok(json!({"mode": m})) +} + +fn slot_of(p: &Value, key: &str, max: usize) -> Option { + let v = p.get(key)?; + let i = if let Some(n) = v.as_u64() { + n as usize + } else { + let s = v.as_str()?.trim().to_ascii_lowercase(); + let c = s.chars().next()?; + if s.len() == 1 && c.is_ascii_lowercase() { (c as u8 - b'a') as usize } else { s.parse().ok()? } + }; + (i < max).then_some(i) +} + +fn track_slot(e: &Engine, p: &Value, cmd: &str, max: usize) -> Result<(TrackId, usize)> { + let t = tracks_required(e, cmd, p)?.first().copied().ok_or_else(|| bad(cmd, "no track"))?; + let slot = slot_of(p, "slot", max).ok_or_else(|| bad(cmd, format!("`slot` must be 0..{} or a..{}", max - 1, (b'a' + max as u8 - 1) as char)))?; + Ok((t, slot)) +} + +fn insert(e: &mut Engine, p: &Value) -> Result { + let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.insert", "`plugin` required"))?.to_string(); + let info = soundcraft_dsp::plugin_info(&plugin).ok_or_else(|| bad("mix.insert", format!("unknown plugin `{plugin}`")))?; + let t = tracks_required(e, "mix.insert", p)?.first().copied().ok_or_else(|| bad("mix.insert", "no track"))?; + let explicit = slot_of(p, "slot", INSERT_SLOTS); + let s = e.session_mut(); + let tr = s.track_mut(t).ok_or_else(|| bad("mix.insert", "no track"))?; + let slot = + explicit.or_else(|| tr.mixer.inserts.iter().position(Option::is_none)).ok_or_else(|| bad("mix.insert", "all insert slots are in use"))?; + let mut ins = Insert::new(info.id); + for pi in info.params { + ins.params.insert(pi.id.to_string(), pi.default); + } + if let Some(obj) = p.get("params").and_then(Value::as_object) { + for (k, v) in obj { + let Some(pi) = info.params.iter().find(|x| x.id == k) else { + return Err(bad("mix.insert", format!("`{plugin}` has no parameter `{k}`"))); + }; + let val = v.as_f64().map_or(pi.default, |f| (f as f32).clamp(pi.min, pi.max)); + ins.params.insert(k.clone(), val); + } + } + if let Some(sl) = tr.mixer.inserts.get_mut(slot) { + *sl = Some(ins); + } + Ok(json!({"track": t, "slot": slot, "plugin": info.id})) +} + +fn insert_remove(e: &mut Engine, p: &Value) -> Result { + let (t, slot) = track_slot(e, p, "mix.insert_remove", INSERT_SLOTS)?; + if let Some(tr) = e.session_mut().track_mut(t) + && let Some(s) = tr.mixer.inserts.get_mut(slot) + { + *s = None; + } + Ok(json!({})) +} + +fn insert_bypass(e: &mut Engine, p: &Value) -> Result { + let (t, slot) = track_slot(e, p, "mix.insert_bypass", INSERT_SLOTS)?; + let val = p.get("value").and_then(Value::as_bool); + let s = e.session_mut(); + let ins = s + .track_mut(t) + .and_then(|tr| tr.mixer.inserts.get_mut(slot)) + .and_then(Option::as_mut) + .ok_or_else(|| bad("mix.insert_bypass", "empty slot"))?; + ins.bypass = val.unwrap_or(!ins.bypass); + Ok(json!({"bypass": ins.bypass})) +} + +fn insert_param(e: &mut Engine, p: &Value) -> Result { + let (t, slot) = track_slot(e, p, "mix.insert_param", INSERT_SLOTS)?; + let param = str_param(p, "param").ok_or_else(|| bad("mix.insert_param", "`param` required"))?.to_string(); + let value = + p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.insert_param", "`value` must be a number"))? as f32; + let s = e.session_mut(); + let ins = + s.track_mut(t).and_then(|tr| tr.mixer.inserts.get_mut(slot)).and_then(Option::as_mut).ok_or_else(|| bad("mix.insert_param", "empty slot"))?; + let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_param", "unknown plugin"))?; + let pi = + info.params.iter().find(|x| x.id == param).ok_or_else(|| bad("mix.insert_param", format!("`{}` has no parameter `{param}`", info.id)))?; + let v = value.clamp(pi.min, pi.max); + ins.params.insert(param, v); + Ok(json!({"value": v})) +} + +fn insert_move(e: &mut Engine, p: &Value) -> Result { + let t = tracks_required(e, "mix.insert_move", p)?.first().copied().ok_or_else(|| bad("mix.insert_move", "no track"))?; + let from = slot_of(p, "from", INSERT_SLOTS).ok_or_else(|| bad("mix.insert_move", "`from` slot"))?; + let to = slot_of(p, "to", INSERT_SLOTS).ok_or_else(|| bad("mix.insert_move", "`to` slot"))?; + if let Some(tr) = e.session_mut().track_mut(t) { + tr.mixer.inserts.swap(from, to); + } + Ok(json!({})) +} + +fn send(e: &mut Engine, p: &Value) -> Result { + let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string(); + let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?; + let explicit = slot_of(p, "slot", SEND_SLOTS); + let level = f32_or(p, "level_db", 0.0).clamp(-144.0, 12.0); + let pre = bool_or(p, "pre_fader", false); + let s = e.session_mut(); + let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo); + let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?; + let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?; + let mut sl = SendSlot::new(soundcraft_model::Route::Bus(bid)); + sl.level_db = level; + sl.pre_fader = pre; + if let Some(x) = tr.mixer.sends.get_mut(slot) { + *x = Some(sl); + } + Ok(json!({"slot": slot, "bus": bid})) +} + +fn send_level(e: &mut Engine, p: &Value) -> Result { + let (t, slot) = track_slot(e, p, "mix.send_level", SEND_SLOTS)?; + let s = e.session_mut(); + let sl = s + .track_mut(t) + .and_then(|tr| tr.mixer.sends.get_mut(slot)) + .and_then(Option::as_mut) + .ok_or_else(|| bad("mix.send_level", "empty send slot"))?; + if let Some(db) = p.get("db").and_then(Value::as_f64) { + sl.level_db = (db as f32).clamp(-144.0, 12.0); + } + if let Some(v) = p.get("pan").and_then(Value::as_f64) { + sl.pan = (v as f32).clamp(-1.0, 1.0); + } + if let Some(m) = p.get("mute").and_then(Value::as_bool) { + sl.mute = m; + } + if let Some(m) = p.get("pre_fader").and_then(Value::as_bool) { + sl.pre_fader = m; + } + Ok(json!({"send": sl.clone()})) +} + +fn lane_param(p: &Value, cmd: &str) -> Result { + str_param(p, "param").map_or(Ok(AutoParam::Volume), |s| AutoParam::parse(s).ok_or_else(|| bad(cmd, format!("unknown automation param `{s}`")))) +} + +fn set_point(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "automation.set_point", p)?; + let param = lane_param(p, "automation.set_point")?; + let at = position_param(e, "automation.set_point", p, "at")?.unwrap_or(e.session().edit.selection.start).max(0); + let v = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("automation.set_point", "`value` required"))? as f32; + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + tr.lane_mut(¶m).set_point(at, v); + } + } + Ok(json!({"at": at, "value": v})) +} + +fn write_range(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "automation.write_range", p)?; + let param = lane_param(p, "automation.write_range")?; + let r = range_param(e, "automation.write_range", p)?; + let v = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("automation.write_range", "`value` required"))? as f32; + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + let def = current_value(tr, ¶m); + tr.lane_mut(¶m).write_range(r.start, r.end, v, def); + } + } + Ok(json!({})) +} + +/// The static (non-automated) value of a parameter on a track. +pub(crate) fn current_value(tr: &soundcraft_model::Track, param: &AutoParam) -> f32 { + match param { + AutoParam::Volume => tr.mixer.volume_db, + AutoParam::Pan(i) => tr.mixer.pan.get(usize::from(*i)).copied().unwrap_or(0.0), + AutoParam::Mute => f32::from(u8::from(tr.mixer.mute)), + AutoParam::SendLevel(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(-144.0, |s| s.level_db), + AutoParam::SendPan(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(0.0, |s| s.pan), + AutoParam::SendMute(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(0.0, |s| f32::from(u8::from(s.mute))), + AutoParam::Plugin { slot, param } => { + tr.mixer.inserts.get(usize::from(*slot)).and_then(Option::as_ref).and_then(|i| i.params.get(param).copied()).unwrap_or(0.0) + } + } +} + +fn clear_lane(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "automation.clear", p)?; + let param = str_param(p, "param").and_then(AutoParam::parse); + let r = range_param(e, "automation.clear", p)?; + let (a, b) = if r.is_empty() { (i64::MIN, i64::MAX) } else { (r.start, r.end) }; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + for l in &mut tr.automation { + if param.as_ref().is_none_or(|x| x == &l.param) { + n += l.clear_range(a, b); + } + } + } + } + Ok(json!({"removed": n})) +} + +fn thin(e: &mut Engine, p: &Value, all: bool) -> Result { + let tracks = if all { e.session().tracks.iter().map(|t| t.id).collect() } else { tracks_required(e, "automation.thin", p)? }; + let tol = f32_or(p, "tolerance", 0.1).clamp(0.0, 100.0); + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + for l in &mut tr.automation { + n += l.thin(tol); + } + } + } + Ok(json!({"removed": n})) +} + +fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result { + let tracks = tracks_required(e, "automation.write_to_current", p)?; + let r = range_param(e, "automation.write_to_current", p)?; + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + let params: Vec = if all { + let mut v = vec![AutoParam::Volume, AutoParam::Pan(0), AutoParam::Mute]; + v.extend(tr.automation.iter().map(|l| l.param.clone())); + v.sort(); + v.dedup(); + v + } else { + vec![AutoParam::parse(&tr.view).unwrap_or(AutoParam::Volume)] + }; + for param in params { + let v = current_value(tr, ¶m); + tr.lane_mut(¶m).write_range(r.start, r.end, v, v); + } + } + } + Ok(json!({})) +} + +fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?; + let r = range_param(e, "automation.volume_to_clip_gain", p)?; + let s = e.session_mut(); + for t in &tracks { + let Some(tr) = s.track_mut(*t) else { continue }; + let lane = tr.lane(&AutoParam::Volume).cloned(); + let base = tr.mixer.volume_db; + let Some(lane) = lane else { continue }; + if let Some(pl) = tr.playlist_mut() { + for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) { + let mut env: Vec<(i64, f32)> = + lane.points.iter().filter(|pt| c.range().contains(pt.at)).map(|pt| (pt.at - c.start, pt.value - base)).collect(); + env.insert(0, (0, lane.value_at(c.start, base) - base)); + env.push((c.length, lane.value_at(c.end(), base) - base)); + c.gain_env = env; + } + } + tr.lane_mut(&AutoParam::Volume).clear_range(r.start, r.end); + } + Ok(json!({})) +} + +fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "automation.clip_gain_to_volume", p)?; + let r = range_param(e, "automation.clip_gain_to_volume", p)?; + let s = e.session_mut(); + for t in &tracks { + let Some(tr) = s.track_mut(*t) else { continue }; + let base = tr.mixer.volume_db; + let mut pts = Vec::new(); + if let Some(pl) = tr.playlist_mut() { + for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) { + if c.gain_env.is_empty() { + pts.push((c.start, base + c.gain_db)); + pts.push((c.end() - 1, base + c.gain_db)); + } else { + for (o, db) in &c.gain_env { + pts.push((c.start + o, base + c.gain_db + db)); + } + } + c.gain_db = 0.0; + c.gain_env.clear(); + } + } + let lane = tr.lane_mut(&AutoParam::Volume); + for (at, v) in pts { + lane.set_point(at, v); + } + } + Ok(json!({})) +} + +fn copy_to_send(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "automation.copy_to_send", p)?; + let slot = slot_of(p, "send", SEND_SLOTS).unwrap_or(0) as u8; + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + let pts = tr.lane(&AutoParam::Volume).map(|l| l.points.clone()).unwrap_or_default(); + let v = tr.mixer.volume_db; + let lane = tr.lane_mut(&AutoParam::SendLevel(slot)); + if pts.is_empty() { + lane.set_point(0, v); + } + for pt in pts { + lane.set_point(pt.at, pt.value); + } + } + } + Ok(json!({})) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs new file mode 100644 index 00000000..dc6663bf --- /dev/null +++ b/crates/engine/src/cmd/mod.rs @@ -0,0 +1,305 @@ +//! The command registry. One module per menu area; each exposes `specs()`. + +use crate::{Engine, EngineError, Result}; +use serde_json::Value; +use soundcraft_model::{ClipId, TrackId}; +use soundcraft_time::{Range, Samples}; +use std::sync::OnceLock; + +mod clip; +mod edit; +mod event; +mod file; +mod mix; +mod options; +mod query; +mod track; +mod transport; +mod view; + +pub type Run = fn(&mut Engine, &Value) -> Result; +/// `Err(reason)` = disabled. +pub type Enabled = fn(&Engine) -> std::result::Result<(), String>; + +/// A registered command. +pub struct CommandSpec { + pub id: &'static str, + pub label: &'static str, + /// Menu path, e.g. `["Edit", "Trim"]`; empty = not in menus. + pub menu: &'static [&'static str], + pub shortcut: Option<&'static str>, + /// Human/agent parameter documentation. + pub params: &'static str, + pub enabled: Enabled, + pub run: Run, + pub journal: bool, + pub undoable: bool, +} + +/// Serializable description of a command. +#[derive(Debug, Clone, serde::Serialize)] +pub struct CommandInfo { + pub id: String, + pub label: String, + pub menu: Vec, + pub shortcut: Option, + pub params: String, + pub enabled: bool, + pub disabled_reason: Option, +} + +impl CommandSpec { + pub fn info(&self, e: &Engine) -> CommandInfo { + let en = (self.enabled)(e); + CommandInfo { + id: self.id.into(), + label: self.label.into(), + menu: self.menu.iter().map(|s| s.to_string()).collect(), + shortcut: self.shortcut.map(str::to_string), + params: self.params.into(), + enabled: en.is_ok(), + disabled_reason: en.err(), + } + } +} + +/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo` variants. +#[macro_export] +macro_rules! cmd { + (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false } + }; + (noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false } + }; + ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true } + }; +} + +pub fn command_specs() -> &'static [CommandSpec] { + static SPECS: OnceLock> = OnceLock::new(); + SPECS.get_or_init(|| { + let mut v = Vec::new(); + v.extend(file::specs()); + v.extend(edit::specs()); + v.extend(view::specs()); + v.extend(track::specs()); + v.extend(clip::specs()); + v.extend(event::specs()); + v.extend(mix::specs()); + v.extend(options::specs()); + v.extend(transport::specs()); + v.extend(query::specs()); + v + }) +} + +pub fn find_command(id: &str) -> Option<&'static CommandSpec> { + command_specs().iter().find(|c| c.id == id) +} + +// ---- enablement -------------------------------------------------------------------------- + +pub fn always(_: &Engine) -> std::result::Result<(), String> { + Ok(()) +} + +pub fn has_tracks(e: &Engine) -> std::result::Result<(), String> { + if e.session().tracks.is_empty() { Err("the session has no tracks".into()) } else { Ok(()) } +} + +pub fn has_selection(e: &Engine) -> std::result::Result<(), String> { + let s = e.session(); + if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() { Err("nothing is selected".into()) } else { Ok(()) } +} + +pub fn has_range(e: &Engine) -> std::result::Result<(), String> { + let s = e.session(); + if (s.edit.selected_tracks.is_empty() || s.edit.selection.is_empty()) && s.edit.selected_clips.is_empty() { + Err("make an edit selection first".into()) + } else { + Ok(()) + } +} + +pub fn can_undo(e: &Engine) -> std::result::Result<(), String> { + if e.can_undo() { Ok(()) } else { Err("nothing to undo".into()) } +} + +pub fn can_redo(e: &Engine) -> std::result::Result<(), String> { + if e.can_redo() { Ok(()) } else { Err("nothing to redo".into()) } +} + +pub fn has_clipboard(e: &Engine) -> std::result::Result<(), String> { + if e.clipboard.tracks.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) } +} + +// ---- params ------------------------------------------------------------------------------ + +pub fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams(cmd.to_string(), msg.into()) +} + +pub fn f64_or(p: &Value, key: &str, d: f64) -> f64 { + p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d) +} + +pub fn f32_or(p: &Value, key: &str, d: f32) -> f32 { + f64_or(p, key, f64::from(d)) as f32 +} + +pub fn i64_or(p: &Value, key: &str, d: i64) -> i64 { + p.get(key).and_then(|v| v.as_i64().or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round() as i64))).unwrap_or(d) +} + +pub fn bool_or(p: &Value, key: &str, d: bool) -> bool { + p.get(key).and_then(Value::as_bool).unwrap_or(d) +} + +pub fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { + p.get(key).and_then(Value::as_str) +} + +/// Resolve a track from `track` (id number or name) params. +pub fn track_param(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result> { + let Some(v) = p.get(key) else { return Ok(None) }; + let s = e.session(); + if let Some(n) = v.as_u64() { + return s.track(TrackId(n)).map(|t| Some(t.id)).ok_or_else(|| bad(cmd, format!("no track with id {n}"))); + } + if let Some(name) = v.as_str() { + return s.track_by_name(name).map(|t| Some(t.id)).ok_or_else(|| bad(cmd, format!("no track named `{name}`"))); + } + Err(bad(cmd, format!("`{key}` must be a track id or name"))) +} + +/// Tracks to act on: `tracks` (array of ids/names) or `track`, else the selected tracks. +pub fn tracks_param(e: &Engine, cmd: &str, p: &Value) -> Result> { + if let Some(arr) = p.get("tracks").and_then(Value::as_array) { + let s = e.session(); + let mut out = Vec::new(); + for v in arr { + let id = match (v.as_u64(), v.as_str()) { + (Some(n), _) => s.track(TrackId(n)).map(|t| t.id), + (_, Some(name)) => s.track_by_name(name).map(|t| t.id), + _ => None, + }; + out.push(id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?); + } + return Ok(out); + } + if let Some(t) = track_param(e, cmd, p, "track")? { + return Ok(vec![t]); + } + Ok(e.target_tracks()) +} + +/// Like `tracks_param` but errors when empty. +pub fn tracks_required(e: &Engine, cmd: &str, p: &Value) -> Result> { + let t = tracks_param(e, cmd, p)?; + if t.is_empty() { Err(bad(cmd, "no tracks: pass `track`/`tracks` or select tracks")) } else { Ok(t) } +} + +/// Position from `key`: a number of samples, or `{"seconds": x}`, or a string in the main counter format. +pub fn position_param(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result> { + let Some(v) = p.get(key) else { return Ok(None) }; + let s = e.session(); + if let Some(n) = v.as_i64() { + return Ok(Some(n)); + } + if let Some(f) = v.as_f64() { + return Ok(Some(soundcraft_time::to_samples(f))); + } + if let Some(secs) = v.get("seconds").and_then(Value::as_f64) { + return Ok(Some(s.sample_rate.samples(secs))); + } + if let Some(text) = v.as_str() { + // Try each format, starting with an explicit prefix like "bars:5|1|000". + let (fmt, body) = match text.split_once(':') { + Some((f, b)) if soundcraft_time::TimeFormat::from_id(f).is_some() => (soundcraft_time::TimeFormat::from_id(f), b), + _ => (None, text), + }; + let fmts: Vec = match fmt { + Some(f) => vec![f], + None => { + let mut v = vec![s.edit.main_counter]; + v.extend(soundcraft_time::TimeFormat::ALL); + v + } + }; + for f in fmts { + if let Ok(x) = soundcraft_time::parse_position(body, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) { + return Ok(Some(x)); + } + } + return Err(bad(cmd, format!("cannot parse position `{text}`"))); + } + Err(bad(cmd, format!("`{key}` must be samples, {{seconds}}, or a time string"))) +} + +/// Range from `start`/`end` params, else the edit selection. +pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result { + let st = position_param(e, cmd, p, "start")?; + let en = position_param(e, cmd, p, "end")?; + let len = position_param(e, cmd, p, "length")?; + 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))), + (Some(a), None, None) => Ok(Range::point(a)), + _ => Ok(e.session().edit.selection), + } +} + +pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec { + if let Some(arr) = p.get("clips").and_then(Value::as_array) { + return arr.iter().filter_map(Value::as_u64).map(ClipId).collect(); + } + if let Some(n) = p.get("clip").and_then(Value::as_u64) { + return vec![ClipId(n)]; + } + let s = e.session(); + if !s.edit.selected_clips.is_empty() { + return s.edit.selected_clips.clone(); + } + // Clips touched by the edit selection on selected tracks. + let r = s.edit.selection; + s.tracks + .iter() + .filter(|t| s.edit.selected_tracks.contains(&t.id)) + .flat_map(|t| t.clips().iter().filter(move |c| if r.is_empty() { c.range().contains(r.start) } else { c.range().overlaps(&r) }).map(|c| c.id)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_unique_and_documented() { + let mut seen = std::collections::HashSet::new(); + for c in command_specs() { + assert!(seen.insert(c.id), "duplicate command id {}", c.id); + assert!(!c.label.is_empty()); + assert!(c.id.contains('.'), "ids are namespaced: {}", c.id); + } + assert!(command_specs().len() > 150, "only {} commands", command_specs().len()); + } + + #[test] + fn every_command_survives_empty_params() { + // No command may panic with `{}` params on an empty or demo session. + for demo in [false, true] { + for c in command_specs() { + if matches!(c.id, "app.quit") { + continue; + } + let mut e = if demo { crate::demo::demo_engine() } else { Engine::default() }; + let r = e.execute(c.id, &serde_json::json!({})); + if let Err(EngineError::Internal(id, msg)) = r { + panic!("{id} panicked: {msg}"); + } + } + } + } +} diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs new file mode 100644 index 00000000..4affff69 --- /dev/null +++ b/crates/engine/src/cmd/options.rs @@ -0,0 +1,119 @@ +//! Options menu toggles and Setup › Session settings. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::{BitDepthSetting, EditState}; +use soundcraft_time::FrameRate; + +macro_rules! toggle { + ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $field:ident) => { + cmd!(noundo $id, $label, [$($m),*], $sc, "{value?: bool}", always, |e, p| { + let s = e.session_mut(); + s.edit.$field = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.$field); + Ok(json!({"value": s.edit.$field})) + }) + }; +} + +pub fn specs() -> Vec { + vec![ + toggle!("options.loop_record", "Loop Record", ["Options"], Some("Alt+L"), loop_record), + toggle!("options.quickpunch", "QuickPunch", ["Options"], Some("Cmd+Shift+P"), quickpunch), + toggle!("options.pre_post_roll", "Pre/Post-Roll", ["Options"], Some("Cmd+K"), pre_post_roll), + toggle!("options.loop_playback", "Loop Playback", ["Options"], Some("Cmd+Shift+L"), loop_playback), + toggle!("options.link_timeline_edit", "Link Timeline and Edit Selection", ["Options"], Some("Shift+/"), link_timeline_edit), + toggle!("options.link_track_edit", "Link Track and Edit Selection", ["Options"], None, link_track_edit), + toggle!("options.insertion_follows_playback", "Insertion Follows Playback", ["Options"], None, insertion_follows_playback), + toggle!("options.tab_to_transient", "Tab to Transient", ["Options"], Some("Cmd+Alt+Tab"), tab_to_transient), + toggle!("options.mirrored_midi", "Mirrored MIDI Editing", ["Options"], None, mirrored_midi), + toggle!("options.automation_follows_edit", "Automation Follows Edit", ["Options"], None, automation_follows_edit), + toggle!("options.markers_follow_edit", "Markers Follow Edit", ["Options"], None, markers_follow_edit), + toggle!("options.layered_editing", "Layered Editing", ["Options"], None, layered_editing), + toggle!("options.click", "Click", ["Options"], None, click), + toggle!("options.countoff", "Count Off", [], None, countoff), + toggle!("options.midi_merge", "MIDI Merge", [], None, midi_merge), + toggle!("options.pre_fader_metering", "Pre-Fader Metering", ["Options"], None, pre_fader_metering), + toggle!("options.delay_compensation", "Delay Compensation", ["Options"], None, delay_compensation), + cmd!(noundo "options.destructive_record", "Destructive Record", ["Options"], None, "{value?: bool}", always, |e, p| record_mode(e, p, "destructive")), + cmd!(noundo "options.trackpunch", "TrackPunch", ["Options"], Some("Cmd+Shift+T"), "{value?: bool}", always, |e, p| record_mode(e, p, "trackpunch")), + cmd!(noundo "options.scrolling", "Edit Window Scrolling", ["Options", "Edit Window Scrolling"], None, "{mode: none|after_playback|page|continuous|center}", always, |e, p| { + let m = str_param(p, "mode").filter(|m| ["none", "after_playback", "page", "continuous", "center"].contains(m)).ok_or_else(|| bad("options.scrolling", "unknown mode"))?.to_string(); + e.session_mut().edit.scrolling = m.clone(); + Ok(json!({"mode": m})) + }), + cmd!(noundo "options.solo_mode", "Solo Mode", ["Options", "Solo Mode"], None, "{mode: sip|afl|pfl, latch?: bool, xor?: bool}", always, |e, p| { + let m = str_param(p, "mode").unwrap_or("sip").to_string(); + let mut v = m.clone(); + if bool_or(p, "xor", false) { v.push_str("+xor") } + e.session_mut().edit.solo_mode = v.clone(); + Ok(json!({"mode": v})) + }), + cmd!(noundo "options.pre_roll", "Pre-Roll Amount", [], None, "{length: samples|{seconds}}", always, |e, p| { + let v = position_param(e, "options.pre_roll", p, "length")?.unwrap_or(0).max(0); + e.session_mut().edit.pre_roll = v; + Ok(json!({"pre_roll": v})) + }), + cmd!(noundo "options.post_roll", "Post-Roll Amount", [], None, "{length}", always, |e, p| { + let v = position_param(e, "options.post_roll", p, "length")?.unwrap_or(0).max(0); + e.session_mut().edit.post_roll = v; + Ok(json!({"post_roll": v})) + }), + cmd!( + "setup.session", + "Session", + ["Setup"], + Some("Cmd+2"), + "{frame_rate?: '29.97 Drop'…, bit_depth?: 16|24|32f, timecode_start?: position, name?}", + always, + session_setup + ), + cmd!(noundo "setup.preferences", "Preferences...", ["Setup"], None, "{}", always, |e, _| Ok(json!({"edit": e.session().edit.clone()}))), + cmd!(noundo "setup.reset_edit_state", "Reset Edit Settings", [], None, "{}", always, |e, _| { + let s = e.session_mut(); + let keep = (s.edit.selection, s.edit.selected_tracks.clone()); + s.edit = EditState::default(); + s.edit.selection = keep.0; + s.edit.selected_tracks = keep.1; + Ok(json!({})) + }), + ] +} + +fn record_mode(e: &mut Engine, p: &Value, mode: &str) -> Result { + let s = e.session_mut(); + let on = p.get("value").and_then(Value::as_bool).unwrap_or(s.edit.record_mode != mode); + s.edit.record_mode = if on { mode.to_string() } else { "normal".into() }; + Ok(json!({"record_mode": s.edit.record_mode})) +} + +fn session_setup(e: &mut Engine, p: &Value) -> Result { + let fr = match str_param(p, "frame_rate") { + Some(f) => { + Some(FrameRate::ALL.into_iter().find(|r| r.label().eq_ignore_ascii_case(f)).ok_or_else(|| bad("setup.session", "unknown frame rate"))?) + } + None => None, + }; + let bd = match p.get("bit_depth").map(|v| v.to_string().trim_matches('"').to_ascii_lowercase()) { + Some(b) if b == "16" => Some(BitDepthSetting::Int16), + Some(b) if b == "24" => Some(BitDepthSetting::Int24), + Some(b) if b.starts_with("32") => Some(BitDepthSetting::Float32), + Some(_) => return Err(bad("setup.session", "bit_depth must be 16, 24 or 32f")), + None => None, + }; + let tc = position_param(e, "setup.session", p, "timecode_start")?; + let s = e.session_mut(); + if let Some(f) = fr { + s.frame_rate = f; + } + if let Some(b) = bd { + s.bit_depth = b; + } + if let Some(t) = tc { + s.timecode_start = t; + } + if let Some(n) = p.get("name").and_then(Value::as_str) { + s.name = n.to_string(); + } + Ok(json!({"frame_rate": s.frame_rate.label(), "bit_depth": s.bit_depth, "sample_rate": s.sample_rate.hz()})) +} diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs new file mode 100644 index 00000000..0501a285 --- /dev/null +++ b/crates/engine/src/cmd/query.rs @@ -0,0 +1,40 @@ +//! Read-only queries for agents. + +use super::*; +use crate::cmd; +use serde_json::json; + +pub fn specs() -> Vec { + vec![ + cmd!(query "session.inspect", "Inspect Session", [], None, "{detail?: summary|full}", always, |e, p| Ok(crate::inspect::session(e, str_param(p, "detail") == Some("full")))), + cmd!(query "track.inspect", "Inspect Track", [], None, "{track}", has_tracks, |e, p| { + let t = track_param(e, "track.inspect", p, "track")?.ok_or_else(|| bad("track.inspect", "`track` required"))?; + crate::inspect::track(e, t).ok_or_else(|| bad("track.inspect", "no such track")) + }), + cmd!(query "clip.inspect", "Inspect Clip", [], None, "{clip}", always, |e, p| { + let id = p.get("clip").and_then(Value::as_u64).ok_or_else(|| bad("clip.inspect", "`clip` id required"))?; + crate::inspect::clip(e, soundcraft_model::ClipId(id)).ok_or_else(|| bad("clip.inspect", "no such clip")) + }), + cmd!(query "engine.commands", "List Commands", [], None, "{filter?}", always, |e, p| { + let f = str_param(p, "filter").map(str::to_ascii_lowercase); + let v: Vec = command_specs() + .iter() + .filter(|c| f.as_ref().is_none_or(|f| c.id.contains(f.as_str()) || c.label.to_ascii_lowercase().contains(f.as_str()))) + .map(|c| c.info(e)) + .collect(); + Ok(json!(v)) + }), + cmd!(query "engine.history", "Undo History", ["Window"], None, "{}", always, |e, _| Ok(json!({"undo": e.undo_history(), "can_redo": e.can_redo()}))), + cmd!(query "engine.plugins", "List Plugins", [], None, "{}", always, |_, _| { + let v: Vec<_> = soundcraft_dsp::plugins().iter().map(|p| json!({"id": p.id, "name": p.name, "category": p.category, "instrument": p.is_instrument, "params": p.params})).collect(); + Ok(json!(v)) + }), + cmd!(query "engine.parity", "Parity Report", [], None, "{}", always, |_, _| Ok(crate::catalog::parity_json())), + cmd!(query "session.format_time", "Format Time", [], None, "{at: samples, format?: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| { + let at = position_param(e, "session.format_time", p, "at")?.unwrap_or(0); + let s = e.session(); + let f = str_param(p, "format").and_then(soundcraft_time::TimeFormat::from_id).unwrap_or(s.edit.main_counter); + Ok(json!({"text": soundcraft_time::format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)})) + }), + ] +} diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs new file mode 100644 index 00000000..2539b28c --- /dev/null +++ b/crates/engine/src/cmd/track.rs @@ -0,0 +1,581 @@ +//! Track menu commands. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, TrackHeight, TrackKind}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "track.new", + "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}", + always, + new_track + ), + cmd!("track.group", "Group...", ["Track"], Some("Cmd+G"), "{name?, tracks?, edit?: true, mix?: true}", has_selection, group), + cmd!("track.ungroup", "Delete Group", [], None, "{group: id|name}", always, delete_group), + cmd!("track.group_toggle", "Toggle Group Active", [], None, "{group: id|name}", always, toggle_group), + cmd!( + "track.duplicate", + "Duplicate...", + ["Track"], + Some("Alt+Shift+D"), + "{tracks?, playlists?: true, automation?: true, inserts?: true, sends?: true}", + has_selection, + duplicate + ), + cmd!("track.split_into_mono", "Split into Mono", ["Track"], None, "{tracks?}", has_selection, split_mono), + cmd!("track.make_inactive", "Make Inactive", ["Track"], None, "{tracks?, inactive?: bool}", has_selection, |e, p| flag( + e, + p, + "track.make_inactive", + |t, v| t.inactive = v, + |t| t.inactive + )), + cmd!( + "track.move_to_new_folder", + "Move to New Folder...", + ["Track"], + Some("Cmd+Alt+Shift+N"), + "{tracks?, name?}", + has_selection, + move_to_folder + ), + cmd!( + "track.change_width", + "Change Track Width", + ["Track", "Change Track Width"], + None, + "{tracks?, format: mono|stereo|…}", + has_selection, + change_width + ), + cmd!("track.delete", "Delete", ["Track"], None, "{tracks?}", has_selection, delete), + cmd!("track.rename", "Rename Track", [], None, "{track, name}", has_tracks, rename), + cmd!("track.comments", "Track Comments", [], None, "{track, comments}", has_tracks, comments), + cmd!("track.color", "Track Color", [], None, "{tracks?, color: [r,g,b] | palette index}", has_selection, color), + cmd!("track.height", "Track Height", [], None, "{tracks?, height: micro|mini|small|medium|large|jumbo|extreme}", has_selection, height), + cmd!("track.view", "Track View", [], None, "{tracks?, view: waveform|blocks|notes|volume|pan|mute|}", has_selection, view), + cmd!("track.hide", "Hide Track", [], None, "{tracks?, hidden?: bool}", has_selection, |e, p| flag( + e, + p, + "track.hide", + |t, v| t.hidden = v, + |t| t.hidden + )), + cmd!("track.pin", "Pin Track", [], None, "{tracks?}", has_selection, |e, p| flag(e, p, "track.pin", |t, v| t.pinned = v, |t| t.pinned)), + cmd!("track.move", "Move Track", [], None, "{track, to: index}", has_tracks, move_track), + cmd!("track.freeze", "Freeze", ["Track"], None, "{tracks?}", has_selection, |e, p| flag( + e, + p, + "track.freeze", + |t, v| t.frozen = v, + |t| t.frozen + )), + cmd!("track.commit", "Commit...", ["Track"], Some("Alt+Shift+C"), "{tracks?}", has_selection, commit), + cmd!("track.bounce", "Bounce...", ["Track"], Some("Cmd+Alt+Shift+B"), "{tracks?}", has_selection, commit), + cmd!("track.bypass_inserts", "All", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, bypass_inserts), + cmd!("track.mute_sends", "All", ["Track", "Mute Sends"], None, "{tracks?, mute?: bool}", has_selection, mute_sends), + cmd!("track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| { + let s = e.session_mut(); + let mut n = 0; + for t in &mut s.tracks { + if t.mixer.record_arm { + t.mixer.input_monitor = true; + n += 1; + } + } + Ok(json!({"tracks": n})) + }), + cmd!(noundo "track.scroll_to", "Scroll to Track...", ["Track"], Some("Cmd+Alt+F"), "{track}", has_tracks, |e, p| { + let t = track_param(e, "track.scroll_to", p, "track")?.ok_or_else(|| bad("track.scroll_to", "`track` required"))?; + let s = e.session_mut(); + s.edit.selected_tracks = vec![t]; + Ok(json!({"track": t})) + }), + cmd!("track.clear_clip_indicators", "Clear All Clip Indicators", ["Track"], Some("Alt+C"), "{}", always, |_, _| Ok(json!({"cleared": true}))), + cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, |_, _| Ok(json!({}))), + cmd!("track.create_click", "Create Click Track", ["Track"], None, "{}", always, create_click), + cmd!("track.playlist_new", "New Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, false)), + cmd!("track.playlist_duplicate", "Duplicate Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, true)), + cmd!("track.playlist_select", "Select Playlist", [], None, "{track?, index | name}", has_selection, select_playlist), + cmd!("track.playlist_delete_unused", "Delete Unused Playlists", [], None, "{track?}", has_selection, |e, p| { + let tracks = tracks_required(e, "track.playlist_delete_unused", p)?; + let s = e.session_mut(); + let mut n = 0; + for t in tracks { + if let Some(tr) = s.track_mut(t) + && let Some(active) = tr.playlists.get(tr.active_playlist).cloned() + { + n += tr.playlists.len() - 1; + tr.playlists = vec![active]; + tr.active_playlist = 0; + } + } + Ok(json!({"deleted": n})) + }), + cmd!("track.input", "Track Input", [], None, "{tracks?, input: none|bus name|hardware name}", has_selection, |e, p| route(e, p, true)), + cmd!("track.output", "Track Output", [], None, "{tracks?, output: main|none|bus name}", has_selection, |e, p| route(e, p, false)), + cmd!("track.timebase", "Track Timebase", [], None, "{tracks?, ticks: bool}", has_selection, |e, p| flag( + e, + p, + "track.timebase", + |t, v| t.ticks_timebase = v, + |t| t.ticks_timebase + )), + cmd!( + "track.elastic", + "Elastic Audio", + [], + None, + "{tracks?, algorithm?: polyphonic|rhythmic|monophonic|varispeed|x-form|none}", + has_selection, + |e, p| { + let tracks = tracks_required(e, "track.elastic", p)?; + let alg = str_param(p, "algorithm").filter(|a| *a != "none").map(str::to_string); + let s = e.session_mut(); + for t in tracks { + if let Some(tr) = s.track_mut(t) { + tr.elastic = alg.clone(); + } + } + Ok(json!({"algorithm": alg})) + } + ), + ] +} + +fn new_track(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}`")))?, + None => TrackKind::Audio, + }; + let default_fmt = if matches!(kind, TrackKind::Master | TrackKind::Aux | TrackKind::Instrument | TrackKind::Folder) { + ChannelFormat::Stereo + } else { + ChannelFormat::Mono + }; + let format = match str_param(p, "format") { + Some(f) => ChannelFormat::from_id(f).ok_or_else(|| bad("track.new", format!("unknown format `{f}`")))?, + None => default_fmt, + }; + let name = str_param(p, "name").map(str::to_string); + let ticks = str_param(p, "timebase").map(|t| t == "ticks"); + let instrument = str_param(p, "instrument").map(str::to_string); + let s = e.session_mut(); + let mut ids = Vec::new(); + for _ in 0..count { + let id = s.add_track(kind, format, name.as_deref()); + if let Some(t) = s.track_mut(id) { + if let Some(tb) = ticks { + t.ticks_timebase = tb; + } + if kind == TrackKind::Instrument { + t.instrument = Some(soundcraft_model::Insert::new(instrument.clone().unwrap_or_else(|| "subtractive_synth".into()))); + } + } + ids.push(id); + } + s.edit.selected_tracks = ids.clone(); + Ok(json!({"tracks": ids})) +} + +fn group(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.group", p)?; + let s = e.session_mut(); + let id = GroupId(s.alloc()); + let letter = (b'a'..=b'z').map(char::from).find(|c| !s.groups.iter().any(|g| g.letter == *c)).unwrap_or('z'); + let name = str_param(p, "name").map_or_else(|| format!("Group {}", s.groups.len() + 1), str::to_string); + let color = s.next_track_color(); + s.groups.push(Group { + id, + name, + letter, + members: tracks, + edit: bool_or(p, "edit", true), + mix: bool_or(p, "mix", true), + active: true, + color, + attributes: vec!["volume".into(), "mute".into(), "solo".into(), "record".into()], + }); + Ok(json!({"group": id})) +} + +fn find_group(e: &Engine, p: &Value, cmd: &str) -> Result { + let s = e.session(); + let g = match (p.get("group").and_then(Value::as_u64), str_param(p, "group")) { + (Some(n), _) => s.groups.iter().find(|g| g.id.0 == n), + (_, Some(name)) => s.groups.iter().find(|g| g.name == name || g.letter.to_string() == name), + _ => None, + }; + g.map(|g| g.id).ok_or_else(|| bad(cmd, "unknown `group`")) +} + +fn delete_group(e: &mut Engine, p: &Value) -> Result { + let g = find_group(e, p, "track.ungroup")?; + e.session_mut().groups.retain(|x| x.id != g); + Ok(json!({})) +} + +fn toggle_group(e: &mut Engine, p: &Value) -> Result { + let g = find_group(e, p, "track.group_toggle")?; + let s = e.session_mut(); + let mut active = false; + if let Some(gr) = s.groups.iter_mut().find(|x| x.id == g) { + gr.active = !gr.active; + active = gr.active; + } + Ok(json!({"active": active})) +} + +fn duplicate(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.duplicate", p)?; + let (pl, auto, ins, snd) = + (bool_or(p, "playlists", true), bool_or(p, "automation", true), bool_or(p, "inserts", true), bool_or(p, "sends", true)); + let s = e.session_mut(); + let mut out = Vec::new(); + for t in tracks { + let Some(idx) = s.track_index(t) else { continue }; + let Some(mut nt) = s.tracks.get(idx).cloned() else { continue }; + nt.id = soundcraft_model::TrackId(s.alloc()); + nt.name = s.unique_track_name(&format!("{}.dup", nt.name)); + if !pl { + for p in &mut nt.playlists { + p.clips.clear(); + } + } else { + for p in &mut nt.playlists { + for c in &mut p.clips { + c.id = s.new_clip_id(); + } + } + } + if !auto { + nt.automation.clear(); + } + if !ins { + nt.mixer.inserts.iter_mut().for_each(|i| *i = None); + } + if !snd { + nt.mixer.sends.iter_mut().for_each(|i| *i = None); + } + out.push(nt.id); + s.tracks.insert((idx + 1).min(s.tracks.len()), nt); + } + s.edit.selected_tracks = out.clone(); + Ok(json!({"tracks": out})) +} + +fn split_mono(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.split_into_mono", p)?; + let s = e.session_mut(); + let mut out = Vec::new(); + for t in tracks { + let Some(src) = s.track(t).cloned() else { continue }; + if src.channels() < 2 || src.kind != TrackKind::Audio { + continue; + } + for ch in 0..src.channels() { + let suffix = match (src.channels(), ch) { + (2, 0) => "L".to_string(), + (2, 1) => "R".to_string(), + _ => format!("{}", ch + 1), + }; + let id = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&format!("{}.{suffix}", src.name))); + let mut clips = src.clips().to_vec(); + for c in &mut clips { + c.id = s.new_clip_id(); + // Mono tracks read channel 0 of the source; record the channel in the name for now. + c.name = format!("{}.{suffix}", c.name); + } + if let Some(tr) = s.track_mut(id) { + tr.mixer.pan = vec![if ch == 0 { -1.0 } else { 1.0 }]; + tr.comments = format!("source_channel={ch}"); + if let Some(pl) = tr.playlist_mut() { + pl.clips = clips; + } + } + out.push(id); + } + } + Ok(json!({"tracks": out})) +} + +fn flag( + e: &mut Engine, + p: &Value, + cmd: &str, + set: fn(&mut soundcraft_model::Track, bool), + get: fn(&soundcraft_model::Track) -> bool, +) -> Result { + let tracks = tracks_required(e, cmd, p)?; + let explicit = p.get("inactive").or_else(|| p.get("hidden")).or_else(|| p.get("value")).or_else(|| p.get("ticks")).and_then(Value::as_bool); + let s = e.session_mut(); + let target = explicit.unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(get))); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + set(tr, target); + } + } + Ok(json!({"value": target, "tracks": tracks})) +} + +fn move_to_folder(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.move_to_new_folder", p)?; + let name = str_param(p, "name").map(str::to_string); + let s = e.session_mut(); + let first_idx = tracks.iter().filter_map(|t| s.track_index(*t)).min().unwrap_or(0); + let fid = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, Some(name.as_deref().unwrap_or("Folder"))); + // Place the folder above its first member. + if let Some(i) = s.track_index(fid) { + let f = s.tracks.remove(i); + s.tracks.insert(first_idx.min(s.tracks.len()), f); + } + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + tr.folder = Some(fid); + } + } + Ok(json!({"folder": fid})) +} + +fn change_width(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.change_width", p)?; + let f = str_param(p, "format").and_then(ChannelFormat::from_id).ok_or_else(|| bad("track.change_width", "`format` required"))?; + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + tr.format = f; + tr.mixer.pan = soundcraft_model::Mixer::new(f).pan; + } + } + Ok(json!({"format": f})) +} + +fn delete(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.delete", p)?; + let s = e.session_mut(); + s.tracks.retain(|t| !tracks.contains(&t.id)); + s.edit.selected_tracks.retain(|t| !tracks.contains(t)); + for g in &mut s.groups { + g.members.retain(|t| !tracks.contains(t)); + } + for t in &mut s.tracks { + if t.folder.is_some_and(|f| tracks.contains(&f)) { + t.folder = None; + } + } + Ok(json!({"deleted": tracks.len()})) +} + +fn rename(e: &mut Engine, p: &Value) -> Result { + let t = track_param(e, "track.rename", p, "track")?.ok_or_else(|| bad("track.rename", "`track` required"))?; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("track.rename", "`name` required"))?; + let s = e.session_mut(); + if s.tracks.iter().any(|x| x.name == name && x.id != t) { + return Err(bad("track.rename", format!("a track named `{name}` already exists"))); + } + if let Some(tr) = s.track_mut(t) { + tr.name = name.chars().take(64).collect(); + } + Ok(json!({"name": name})) +} + +fn comments(e: &mut Engine, p: &Value) -> Result { + let t = track_param(e, "track.comments", p, "track")?.ok_or_else(|| bad("track.comments", "`track` required"))?; + let c = str_param(p, "comments").unwrap_or_default().to_string(); + if let Some(tr) = e.session_mut().track_mut(t) { + tr.comments = c; + } + Ok(json!({})) +} + +fn color(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.color", p)?; + let c = match p.get("color") { + Some(Value::Array(a)) if a.len() == 3 => { + let v: Vec = a.iter().map(|x| x.as_u64().unwrap_or(0).min(255) as u8).collect(); + [v.first().copied().unwrap_or(0), v.get(1).copied().unwrap_or(0), v.get(2).copied().unwrap_or(0)] + } + Some(v) if v.is_u64() => soundcraft_model::TRACK_COLORS.get(v.as_u64().unwrap_or(0) as usize % 16).copied().unwrap_or([128, 128, 128]), + _ => return Err(bad("track.color", "`color` must be [r,g,b] or a palette index")), + }; + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + tr.color = c; + } + } + Ok(json!({"color": c})) +} + +fn height(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.height", p)?; + let h = str_param(p, "height").and_then(TrackHeight::from_id).ok_or_else(|| bad("track.height", "unknown height"))?; + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + tr.height = h; + } + } + Ok(json!({"height": h})) +} + +fn view(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.view", p)?; + let v = str_param(p, "view").ok_or_else(|| bad("track.view", "`view` required"))?.to_string(); + let ok = matches!(v.as_str(), "waveform" | "blocks" | "notes" | "regions" | "clip_gain") || soundcraft_model::AutoParam::parse(&v).is_some(); + if !ok { + return Err(bad("track.view", format!("unknown view `{v}`"))); + } + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + tr.view = v.clone(); + } + } + Ok(json!({"view": v})) +} + +fn move_track(e: &mut Engine, p: &Value) -> Result { + let t = track_param(e, "track.move", p, "track")?.ok_or_else(|| bad("track.move", "`track` required"))?; + let to = i64_or(p, "to", 0).max(0) as usize; + let s = e.session_mut(); + if let Some(i) = s.track_index(t) { + let tr = s.tracks.remove(i); + s.tracks.insert(to.min(s.tracks.len()), tr); + } + Ok(json!({})) +} + +fn commit(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.commit", p)?; + let mut out = Vec::new(); + for t in tracks { + if let Some(id) = crate::io::commit_track(e, t)? { + out.push(id); + } + } + Ok(json!({"tracks": out})) +} + +fn bypass_inserts(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.bypass_inserts", p)?; + let s = e.session_mut(); + let target = p + .get("bypass") + .and_then(Value::as_bool) + .unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(|tr| tr.mixer.inserts.iter().flatten().all(|i| i.bypass)))); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + for i in tr.mixer.inserts.iter_mut().flatten() { + i.bypass = target; + } + } + } + Ok(json!({"bypass": target})) +} + +fn mute_sends(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.mute_sends", p)?; + let s = e.session_mut(); + let target = p.get("mute").and_then(Value::as_bool).unwrap_or(true); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + for snd in tr.mixer.sends.iter_mut().flatten() { + snd.mute = target; + } + } + } + Ok(json!({"mute": target})) +} + +fn create_click(e: &mut Engine, _: &Value) -> Result { + let s = e.session_mut(); + let id = s.add_track(TrackKind::Aux, ChannelFormat::Mono, Some("Click")); + if let Some(t) = s.track_mut(id) { + t.comments = "click".into(); + } + s.edit.click = true; + Ok(json!({"track": id})) +} + +fn new_playlist(e: &mut Engine, p: &Value, dup: bool) -> Result { + let tracks = tracks_required(e, "track.playlist_new", p)?; + let name = str_param(p, "name").map(str::to_string); + let s = e.session_mut(); + for t in &tracks { + let mut clips = Vec::new(); + if dup && let Some(tr) = s.track(*t) { + clips = tr.clips().to_vec(); + } + for c in &mut clips { + c.id = s.new_clip_id(); + } + if let Some(tr) = s.track_mut(*t) { + let n = name.clone().unwrap_or_else(|| format!("{}.{:02}", tr.name, tr.playlists.len() + 1)); + tr.playlists.push(Playlist { name: n, clips }); + tr.active_playlist = tr.playlists.len() - 1; + } + } + Ok(json!({"tracks": tracks})) +} + +fn select_playlist(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.playlist_select", p)?; + let idx = p.get("index").and_then(Value::as_u64).map(|i| i as usize); + let name = str_param(p, "name").map(str::to_string); + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + let i = idx.or_else(|| name.as_ref().and_then(|n| tr.playlists.iter().position(|p| &p.name == n))); + match i { + Some(i) if i < tr.playlists.len() => tr.active_playlist = i, + _ => return Err(bad("track.playlist_select", "no such playlist")), + } + } + } + Ok(json!({})) +} + +pub(crate) fn parse_route(s: &soundcraft_model::Session, v: &str) -> Route { + match v.to_ascii_lowercase().as_str() { + "" | "none" | "no input" | "no output" => Route::None, + "main" | "out 1-2" | "master" => Route::Main, + _ => s.bus_by_name(v).map_or_else(|| Route::Hardware(v.to_string()), |b| Route::Bus(b.id)), + } +} + +fn route(e: &mut Engine, p: &Value, input: bool) -> Result { + let key = if input { "input" } else { "output" }; + let tracks = tracks_required(e, "track.route", p)?; + let v = str_param(p, key).ok_or_else(|| bad("track.route", format!("`{key}` required")))?.to_string(); + let create_bus = bool_or(p, "create_bus", true); + let s = e.session_mut(); + let mut r = parse_route(s, &v); + if let Route::Hardware(name) = &r + && create_bus + && !name.to_ascii_lowercase().starts_with("in ") + { + let fmt = tracks + .first() + .and_then(|t| s.track(*t)) + .map_or(ChannelFormat::Stereo, |t| if t.kind == TrackKind::Aux { t.format } else { ChannelFormat::Stereo }); + r = Route::Bus(s.add_bus(&name.clone(), fmt)); + } + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + if input { + tr.mixer.input = r.clone(); + } else { + tr.mixer.output = r.clone(); + } + } + } + Ok(json!({key: r})) +} diff --git a/crates/engine/src/cmd/transport.rs b/crates/engine/src/cmd/transport.rs new file mode 100644 index 00000000..10150269 --- /dev/null +++ b/crates/engine/src/cmd/transport.rs @@ -0,0 +1,57 @@ +//! Transport commands. They update the session's stopped playhead and queue requests for the +//! host's audio engine (which may be absent when headless). + +use super::*; +use crate::{TransportRequest, cmd}; +use serde_json::json; + +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::Play); + Ok(json!({})) + }), + cmd!(noundo "transport.stop", "Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Stop); Ok(json!({})) }), + cmd!(noundo "transport.toggle", "Play/Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::TogglePlay); Ok(json!({})) }), + cmd!(noundo "transport.record", "Record", [], Some("Cmd+Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Record); Ok(json!({})) }), + cmd!(noundo "transport.pause", "Pause", [], None, "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Pause); Ok(json!({})) }), + cmd!(noundo "transport.half_speed", "Half-Speed Playback", [], Some("Shift+Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::HalfSpeed); Ok(json!({})) }), + cmd!(noundo "transport.play_selection", "Play Edit", ["Edit", "Selection"], Some("Alt+["), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::PlaySelection); Ok(json!({})) }), + cmd!(noundo "transport.rtz", "Return to Zero", [], Some("Home"), "{}", always, |e, _| locate(e, 0)), + cmd!(noundo "transport.go_to_end", "Go to End", [], Some("End"), "{}", always, |e, _| { let end = e.session().content_end(); locate(e, end) }), + cmd!(noundo "transport.rewind", "Rewind", [], None, "{seconds?: 1}", always, |e, p| { + let s = e.session(); + let d = s.sample_rate.samples(f64_or(p, "seconds", 1.0)); + let at = (cur(e) - d).max(0); + locate(e, at) + }), + cmd!(noundo "transport.fast_forward", "Fast Forward", [], None, "{seconds?: 1}", always, |e, p| { + let s = e.session(); + let d = s.sample_rate.samples(f64_or(p, "seconds", 1.0)); + let at = cur(e) + d; + locate(e, at) + }), + cmd!(noundo "transport.locate", "Locate", [], None, "{at: position}", always, |e, p| { + let at = position_param(e, "transport.locate", p, "at")?.ok_or_else(|| bad("transport.locate", "`at` required"))?; + locate(e, at.max(0)) + }), + cmd!(noundo "transport.status", "Transport Status", [], None, "{}", always, |e, _| Ok(json!(e.transport))), + ] +} + +fn cur(e: &Engine) -> i64 { + if e.transport.playing { e.transport.position } else { e.session().edit.selection.start } +} + +fn locate(e: &mut Engine, at: i64) -> Result { + let s = e.session_mut(); + s.edit.playhead = at; + if s.edit.selection.is_empty() || !s.edit.selection.contains(at) { + s.edit.selection = soundcraft_time::Range::point(at); + } + e.transport_requests.push(TransportRequest::Locate(at)); + Ok(json!({"at": at})) +} diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs new file mode 100644 index 00000000..9f24e3cf --- /dev/null +++ b/crates/engine/src/cmd/view.rs @@ -0,0 +1,160 @@ +//! View menu commands that change saved view state (zoom, rulers, counters, grid/nudge). + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_time::{GridValue, NoteValue, TimeFormat}; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5)), + cmd!(noundo "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0)), + cmd!(noundo "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| { + let v = f64_or(p, "samples_per_px", 1024.0).clamp(0.25, 1_000_000.0); + e.session_mut().edit.zoom.samples_per_px = v; + Ok(json!({"samples_per_px": v})) + }), + cmd!(noundo "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| { + let i = (i64_or(p, "preset", 1).clamp(1, 5) - 1) as usize; + let s = e.session_mut(); + if bool_or(p, "store", false) { + if let Some(z) = s.edit.zoom.presets.get_mut(i) { *z = s.edit.zoom.samples_per_px } + } else if let Some(z) = s.edit.zoom.presets.get(i) { + s.edit.zoom.samples_per_px = *z; + } + Ok(json!({"samples_per_px": s.edit.zoom.samples_per_px})) + }), + cmd!(noundo "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| { + let w = f64_or(p, "width_px", 1200.0).max(100.0); + let s = e.session_mut(); + let r = s.edit.selection; + if !r.is_empty() { + s.edit.zoom.samples_per_px = (r.len() as f64 / w).max(0.25); + s.edit.zoom.scroll = r.start; + } + Ok(json!({})) + }), + cmd!(noundo "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| { + let w = f64_or(p, "width_px", 1200.0).max(100.0); + let s = e.session_mut(); + let end = s.content_end().max(s.sample_rate.samples(30.0)); + s.edit.zoom.samples_per_px = (end as f64 * 1.05 / w).max(0.25); + s.edit.zoom.scroll = 0; + Ok(json!({})) + }), + cmd!(noundo "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| { + let to = position_param(e, "view.scroll", p, "to")?; + let by = f64_or(p, "by_px", 0.0); + let s = e.session_mut(); + let spp = s.edit.zoom.samples_per_px; + s.edit.zoom.scroll = to.unwrap_or(s.edit.zoom.scroll + soundcraft_time::to_samples(by * spp)).max(0); + Ok(json!({"scroll": s.edit.zoom.scroll})) + }), + cmd!(noundo "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| { + let s = e.session_mut(); + let z = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { s.edit.zoom.waveform_zoom * f32_or(p, "factor", 2.0) }; + s.edit.zoom.waveform_zoom = if z.is_finite() { z.clamp(0.25, 64.0) } else { 1.0 }; + Ok(json!({"waveform_zoom": s.edit.zoom.waveform_zoom})) + }), + cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler), + cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| { + let f = str_param(p, "format").and_then(TimeFormat::from_id).ok_or_else(|| bad("view.main_counter", "unknown format"))?; + e.session_mut().edit.main_counter = f; + Ok(json!({"format": f})) + }), + cmd!(noundo "view.sub_counter", "Sub Counter", [], None, "{format?: … | null}", always, |e, p| { + let f = str_param(p, "format").and_then(TimeFormat::from_id); + e.session_mut().edit.sub_counter = f; + Ok(json!({"format": f})) + }), + cmd!(noundo "view.grid", "Grid Value", [], None, "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, lines?: bool}", always, |e, p| { + let g = grid_param(p, "value").ok_or_else(|| bad("view.grid", "unknown grid value"))?; + let lines = p.get("lines").and_then(Value::as_bool); + let s = e.session_mut(); + s.edit.grid = g; + if let Some(l) = lines { s.edit.grid_lines = l } + Ok(json!({"grid": g.label()})) + }), + cmd!(noundo "view.grid_lines", "Show Grid Lines", [], None, "{value?: bool}", always, |e, p| { + let s = e.session_mut(); + s.edit.grid_lines = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.grid_lines); + Ok(json!({"value": s.edit.grid_lines})) + }), + cmd!(noundo "view.nudge", "Nudge Value", [], None, "{value: like grid}", always, |e, p| { + let g = grid_param(p, "value").ok_or_else(|| bad("view.nudge", "unknown nudge value"))?; + e.session_mut().edit.nudge = g; + Ok(json!({"nudge": g.label()})) + }), + ] +} + +fn zoom(e: &mut Engine, f: f64) -> Result { + let s = e.session_mut(); + let anchor = s.edit.selection.start; + let old = s.edit.zoom.samples_per_px; + let new = (old * f).clamp(0.25, 1_000_000.0); + // Keep the insertion point at the same screen x when it is visible. + let x = (anchor - s.edit.zoom.scroll) as f64 / old; + if (0.0..4000.0).contains(&x) { + s.edit.zoom.scroll = (anchor - soundcraft_time::to_samples(x * new)).max(0); + } + s.edit.zoom.samples_per_px = new; + Ok(json!({"samples_per_px": new})) +} + +fn ruler(e: &mut Engine, p: &Value) -> Result { + const ALL: [&str; 10] = ["bars_beats", "min_secs", "timecode", "feet_frames", "samples", "tempo", "meter", "markers", "key", "chords"]; + let r = str_param(p, "ruler").filter(|r| ALL.contains(r)).ok_or_else(|| bad("view.ruler", "unknown ruler"))?.to_string(); + let s = e.session_mut(); + let shown = s.edit.rulers.contains(&r); + let vis = p.get("visible").and_then(Value::as_bool).unwrap_or(!shown); + s.edit.rulers.retain(|x| x != &r); + if vis { + s.edit.rulers.push(r.clone()); + s.edit.rulers.sort_by_key(|x| ALL.iter().position(|a| a == x)); + } + Ok(json!({"ruler": r, "visible": vis})) +} + +/// Parse grid/nudge values. +pub fn grid_param(p: &Value, key: &str) -> Option { + let v = p.get(key)?; + if let Some(sec) = v.get("seconds").and_then(Value::as_f64) { + return sec.is_finite().then_some(GridValue::Seconds(sec.clamp(0.000_01, 3600.0))); + } + if let Some(f) = v.get("frames").and_then(Value::as_i64) { + return Some(GridValue::Frames(f.clamp(1, 100_000))); + } + if let Some(n) = v.get("samples").and_then(Value::as_i64) { + return Some(GridValue::Samples(n.clamp(1, i64::from(u32::MAX)))); + } + let s = v.as_str()?.trim().to_ascii_lowercase(); + let triplet = s.ends_with('t') || s.contains("triplet"); + let dotted = s.ends_with('.') || s.contains("dotted"); + let core: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '/' || *c == ' ' || c.is_ascii_alphabetic() && *c != 't').collect(); + let value = match core.trim() { + "1 bar" | "bar" | "1/1" | "1" => NoteValue::Bar, + "1/2" => NoteValue::Half, + "1/4" => NoteValue::Quarter, + "1/8" => NoteValue::Eighth, + "1/16" => NoteValue::Sixteenth, + "1/32" => NoteValue::ThirtySecond, + "1/64" => NoteValue::SixtyFourth, + _ => return None, + }; + Some(GridValue::Note { value, dotted, triplet }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grid_values_parse() { + assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false })); + assert_eq!(grid_param(&json!({"v": "1/8t"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true })); + assert_eq!(grid_param(&json!({"v": "1/4."}), "v"), Some(GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false })); + assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5))); + assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None); + } +} diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs new file mode 100644 index 00000000..f0242db1 --- /dev/null +++ b/crates/engine/src/demo.rs @@ -0,0 +1,347 @@ +//! A demo session whose audio is synthesised in code (no third-party media): +//! a 24-bar groove at 104 BPM with drums, bass, keys, pad, lead, a reverb aux and a master. + +use crate::Engine; +use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_midi::{Note, Sequence}; +use soundcraft_model::{ChannelFormat, Clip, Fade, FadeShape, Insert, MarkerKind, Route, SendSlot, Session, TrackKind}; +use soundcraft_time::{SampleRate, TICKS_PER_QUARTER}; + +const BPM: f64 = 104.0; +const BARS: usize = 24; + +/// A small deterministic PRNG for noise. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + ((self.0 >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0 + } +} + +fn insert(id: &str) -> Insert { + let mut i = Insert::new(id); + if let Some(info) = soundcraft_dsp::plugin_info(id) { + for p in info.params { + i.params.insert(p.id.to_string(), p.default); + } + } + i +} + +fn beat_len(sr: f64) -> usize { + (sr * 60.0 / BPM) as usize +} + +fn kick(sr: f64, frames: usize, out: &mut [f32], at: usize, vel: f32) { + let len = (sr * 0.45) as usize; + let mut phase = 0.0f64; + for i in 0..len.min(frames.saturating_sub(at)) { + let t = i as f64 / sr; + let f = 48.0 + 110.0 * (-t * 28.0).exp(); + phase += 2.0 * std::f64::consts::PI * f / sr; + let env = (-t * 7.5).exp() as f32; + let click = if i < 64 { (64 - i) as f32 / 64.0 * 0.3 } else { 0.0 }; + if let Some(o) = out.get_mut(at + i) { + *o += ((phase.sin() as f32) * env + click) * vel * 0.9; + } + } +} + +fn snare(sr: f64, frames: usize, out: &mut [f32], at: usize, vel: f32, rng: &mut Rng) { + let len = (sr * 0.3) as usize; + let mut lp = 0.0f32; + for i in 0..len.min(frames.saturating_sub(at)) { + let t = i as f64 / sr; + let n = rng.next(); + lp += 0.55 * (n - lp); + let body = ((2.0 * std::f64::consts::PI * 185.0 * t).sin() * (-t * 22.0).exp()) as f32; + let env = (-t * 14.0).exp() as f32; + if let Some(o) = out.get_mut(at + i) { + *o += ((n - lp) * env * 0.8 + body * 0.5) * vel * 0.7; + } + } +} + +fn hat(sr: f64, frames: usize, out: &mut [f32], at: usize, vel: f32, open: bool, rng: &mut Rng) { + let len = (sr * if open { 0.35 } else { 0.06 }) as usize; + let mut prev = 0.0f32; + for i in 0..len.min(frames.saturating_sub(at)) { + let t = i as f64 / sr; + let n = rng.next(); + let hp = n - prev; + prev = n; + let env = (-t * if open { 9.0 } else { 70.0 }).exp() as f32; + if let Some(o) = out.get_mut(at + i) { + *o += hp * env * vel * 0.25; + } + } +} + +fn drums(sr: f64, frames: usize) -> (Vec, Vec, Vec) { + let mut k = vec![0.0; frames]; + let mut s = vec![0.0; frames]; + let mut h = vec![0.0; frames]; + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + let beat = beat_len(sr); + for bar in 0..BARS { + let b0 = bar * beat * 4; + // Intro: hats only for 4 bars. + let full = bar >= 4; + if full { + for (pos, vel) in [(0usize, 1.0f32), (2 * beat + beat / 2, 0.8), (3 * beat, 0.6)] { + kick(sr, frames, &mut k, b0 + pos, vel); + } + for pos in [beat, 3 * beat] { + snare(sr, frames, &mut s, b0 + pos, 1.0, &mut rng); + } + if bar % 4 == 3 { + snare(sr, frames, &mut s, b0 + 3 * beat + beat / 2, 0.5, &mut rng); + snare(sr, frames, &mut s, b0 + 3 * beat + 3 * beat / 4, 0.7, &mut rng); + } + } + for i in 0..8 { + let open = full && i == 7; + hat(sr, frames, &mut h, b0 + i * beat / 2, if i % 2 == 0 { 1.0 } else { 0.6 }, open, &mut rng); + } + } + (k, s, h) +} + +fn midi_hz(n: f64) -> f64 { + 440.0 * 2f64.powf((n - 69.0) / 12.0) +} + +/// Chord roots per bar (A minor, F, C, G progression), as MIDI notes. +const PROG: [[u8; 4]; 4] = [[57, 60, 64, 69], [53, 57, 60, 65], [48, 52, 55, 60], [55, 59, 62, 67]]; + +fn bass(sr: f64, frames: usize) -> Vec { + let mut out = vec![0.0; frames]; + let beat = beat_len(sr); + let mut lp = 0.0f64; + for bar in 4..BARS { + let root = f64::from(PROG[bar % 4][0]) - 24.0; + let pattern = [(0usize, 0.0f64, 1.5f64), (2, 0.0, 0.5), (3, 12.0, 0.5), (5, 0.0, 0.5), (6, 7.0, 1.0)]; + for (eighth, semi, beats) in pattern { + let at = bar * beat * 4 + eighth * beat / 2; + let len = (beats * beat as f64) as usize; + let f = midi_hz(root + semi); + let mut ph = 0.0f64; + for i in 0..len { + let Some(o) = out.get_mut(at + i) else { break }; + ph = (ph + f / sr) % 1.0; + let saw = 2.0 * ph - 1.0; + let t = i as f64 / sr; + let cutoff = 0.02 + 0.12 * (-t * 9.0).exp(); + lp += cutoff * (saw - lp); + let env = (1.0 - (-t * 200.0).exp()) * if i + 400 > len { (len - i) as f64 / 400.0 } else { 1.0 }; + *o += (lp * env * 0.55) as f32; + } + } + } + out +} + +fn pad(sr: f64, frames: usize) -> (Vec, Vec) { + let mut l = vec![0.0; frames]; + let mut r = vec![0.0; frames]; + let bar_len = beat_len(sr) * 4; + for bar in 0..BARS { + let chord = PROG[bar % 4]; + let at = bar * bar_len; + for (vi, n) in chord.iter().enumerate() { + let f = midi_hz(f64::from(*n) + 12.0); + for (side, buf) in [(-1.0f64, &mut l), (1.0, &mut r)] { + let det = 1.0 + side * 0.0035 * (vi as f64 + 1.0); + let mut ph = vi as f64 * 0.17; + for i in 0..bar_len { + let Some(o) = buf.get_mut(at + i) else { break }; + ph = (ph + f * det / sr) % 1.0; + let tri = 1.0 - 4.0 * (ph - 0.5).abs(); + let t = i as f64 / bar_len as f64; + let env = (t * 8.0).min(1.0) * ((1.0 - t) * 6.0).min(1.0); + *o += (tri * env * 0.07) as f32; + } + } + } + } + (l, r) +} + +fn note(pitch: u8, start: i64, len: i64, vel: u8) -> Note { + Note { pitch, velocity: vel, release_velocity: 64, channel: 0, start, length: len } +} + +fn keys_sequence() -> Sequence { + let q = TICKS_PER_QUARTER; + let mut seq = Sequence::default(); + for bar in 0..(BARS - 4) as i64 { + let chord = PROG[(bar as usize + 4) % 4]; + for (k, offs) in [(0i64, 0i64), (1, q + q / 2), (2, 3 * q)] { + let _ = k; + for n in &chord[1..] { + seq.notes.push(note(*n, bar * 4 * q + offs, q - 40, 92)); + } + } + } + seq.sort(); + seq +} + +fn lead_sequence() -> Sequence { + let q = TICKS_PER_QUARTER; + let e = q / 2; + let phrase: [(u8, i64, i64); 10] = + [(76, 0, 3), (74, 3, 1), (72, 4, 2), (74, 6, 2), (76, 8, 4), (79, 12, 2), (77, 14, 2), (76, 16, 6), (72, 22, 2), (74, 24, 8)]; + let mut seq = Sequence::default(); + for rep in 0..2i64 { + for (p, st, ln) in phrase { + seq.notes.push(note(p, rep * 32 * e + st * e, ln * e - 30, 100)); + } + } + seq.sort(); + seq +} + +/// Build the demo session. +pub fn demo_session() -> Session { + let sr = SampleRate::HZ_48000; + let mut s = Session::new("Midnight Groove", sr); + let _ = s.tempo.set_tempo(0, BPM); + let srf = sr.as_f64(); + let bar = beat_len(srf) * 4; + let total = bar * BARS + (srf * 2.0) as usize; + let (k, sn, h) = drums(srf, total); + let b = bass(srf, total); + let (pl, pr) = pad(srf, total); + let verb_bus = s.add_bus("Verb", ChannelFormat::Stereo); + let add_audio = |s: &mut Session, name: &str, chans: Vec>, start_bar: usize, color: Option| { + let fmt = ChannelFormat::for_channels(chans.len()); + let buf = AudioBuffer { sample_rate: sr.hz(), channels: chans }; + let src = crate::io::add_source(s, name, buf, None, FileFormat::Wav); + let t = s.add_track(TrackKind::Audio, fmt, Some(name)); + if let (Some(c), Some(tr)) = (color, s.track_mut(t)) { + tr.color = soundcraft_model::TRACK_COLORS[c % 16]; + } + let start = (start_bar * bar) as i64; + let len = (total - start_bar * bar) as i64; + let cid = s.new_clip_id(); + let mut clip = Clip::audio(cid, name, src, start, start, len - (srf * 1.5) as i64); + clip.fade_out = Fade { len: (srf * 0.5) as i64, shape: FadeShape::SCurve }; + crate::edit::place_clip(s, t, clip); + t + }; + let tk = add_audio(&mut s, "Kick", vec![k], 4, Some(7)); + let ts = add_audio(&mut s, "Snare", vec![sn], 4, Some(6)); + let th = add_audio(&mut s, "Hats", vec![h], 0, Some(5)); + let tb = add_audio(&mut s, "Bass", vec![b], 4, Some(10)); + let tp = add_audio(&mut s, "Pad", vec![pl, pr], 0, Some(1)); + // Split the snare into verse/chorus clips so the playlist shows several clips. + for at in [12, 20] { + crate::edit::separate_at(&mut s, ts, (at * bar) as i64); + crate::edit::separate_at(&mut s, tk, (at * bar) as i64); + } + for (i, at) in [8usize, 16].iter().enumerate() { + crate::edit::separate_at(&mut s, tb, (*at * bar) as i64); + let _ = i; + } + // Instrument tracks with MIDI. + let keys = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let lead = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Lead")); + for (t, seq, start_bar, bars, color) in [(keys, keys_sequence(), 4usize, BARS - 4, 2usize), (lead, lead_sequence(), 12, 8, 9)] { + let start = (start_bar * bar) as i64; + let len = (bars * bar) as i64; + let cid = s.new_clip_id(); + let name = s.track(t).map(|x| x.name.clone()).unwrap_or_default(); + crate::edit::place_clip(&mut s, t, Clip::midi(cid, name, start, len, seq)); + if let Some(tr) = s.track_mut(t) { + tr.instrument = Some(insert("subtractive_synth")); + tr.color = soundcraft_model::TRACK_COLORS[color]; + tr.mixer.volume_db = if t == keys { -9.0 } else { -12.0 }; + } + } + // Reverb aux. + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Verb")); + if let Some(tr) = s.track_mut(aux) { + tr.mixer.input = Route::Bus(verb_bus); + tr.mixer.inserts[0] = Some(insert("plate_reverb")); + tr.mixer.volume_db = -4.0; + tr.color = soundcraft_model::TRACK_COLORS[12]; + } + for (t, lvl) in [(ts, -10.0f32), (tp, -8.0), (keys, -12.0), (lead, -6.0)] { + if let Some(tr) = s.track_mut(t) { + let mut snd = SendSlot::new(Route::Bus(verb_bus)); + snd.level_db = lvl; + tr.mixer.sends[0] = Some(snd); + } + } + // Mix moves. + for (t, db, pan) in [(tk, -3.0f32, 0.0f32), (ts, -4.0, 0.0), (th, -10.0, 0.35), (tb, -5.0, 0.0), (tp, -8.0, 0.0)] { + if let Some(tr) = s.track_mut(t) { + tr.mixer.volume_db = db; + if tr.mixer.pan.len() == 1 { + tr.mixer.pan = vec![pan]; + } + } + } + if let Some(tr) = s.track_mut(tk) { + tr.mixer.inserts[0] = Some(insert("eq_7band")); + tr.mixer.inserts[1] = Some(insert("compressor")); + } + if let Some(tr) = s.track_mut(tb) { + tr.mixer.inserts[0] = Some(insert("compressor")); + tr.mixer.inserts[1] = Some(insert("saturator")); + } + if let Some(tr) = s.track_mut(tp) { + tr.mixer.inserts[0] = Some(insert("chorus")); + // Pad swells in with volume automation. + let l = tr.lane_mut(&soundcraft_model::AutoParam::Volume); + l.set_point(0, -30.0); + l.set_point((4 * bar) as i64, -8.0); + l.set_point((20 * bar) as i64, -8.0); + l.set_point((24 * bar) as i64, -24.0); + } + let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, Some("Master")); + if let Some(tr) = s.track_mut(m) { + tr.mixer.inserts[0] = Some(insert("maximizer")); + } + // Markers. + for (name, at) in [("Intro", 0usize), ("Verse", 4), ("Chorus", 12), ("Outro", 20)] { + s.add_marker(name, MarkerKind::Marker, (at * bar) as i64, (at * bar) as i64); + } + s.edit.selected_tracks = vec![tk]; + s.edit.selection = soundcraft_time::Range::point((4 * bar) as i64); + s.edit.zoom.samples_per_px = (total as f64 / 1400.0).max(1.0); + s.edit.main_counter = soundcraft_time::TimeFormat::BarsBeats; + s +} + +/// The demo is deterministic; build it once per process and clone (sources are shared `Arc`s). +pub fn cached_demo_session() -> Session { + static DEMO: std::sync::OnceLock = std::sync::OnceLock::new(); + DEMO.get_or_init(demo_session).clone() +} + +pub fn demo_engine() -> Engine { + Engine::new(cached_demo_session()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn demo_builds_and_renders_sound() { + let s = demo_session(); + assert!(s.tracks.len() >= 9); + assert!(s.master().is_some()); + let sr = s.sample_rate; + let r = soundcraft_time::Range::new(sr.samples(10.0), sr.samples(12.0)); + let out = soundcraft_mix::render_range(&s, r, 1024); + let pk = out.iter().flat_map(|c| c.iter()).fold(0.0f32, |m, v| m.max(v.abs())); + assert!(pk > 0.05, "demo should be audible, peak {pk}"); + assert!(pk <= 1.5, "demo should not be wildly clipping, peak {pk}"); + } +} diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs new file mode 100644 index 00000000..fd248de8 --- /dev/null +++ b/crates/engine/src/edit.rs @@ -0,0 +1,505 @@ +//! Editing algorithms on playlists, independent of the command layer. +//! +//! Model rules: clips on a playlist are sorted and do not overlap, except where two clips +//! overlap exactly over a crossfade (the earlier clip fades out while the later fades in). +//! Placing a clip overwrites whatever is under it (like the incumbent's Slip mode); Shuffle +//! mode also ripples later clips. + +use soundcraft_model::{Clip, ClipContent, ClipId, Fade, FadeShape, Session, TrackId}; +use soundcraft_time::{Range, SampleRate, Samples, TempoMap}; + +/// Context needed to split MIDI clips in musical time. +#[derive(Clone, Copy)] +pub struct TimeCtx<'a> { + pub tempo: &'a TempoMap, + pub sr: SampleRate, +} + +impl<'a> TimeCtx<'a> { + pub fn of(s: &'a Session) -> Self { + TimeCtx { tempo: &s.tempo, sr: s.sample_rate } + } + fn ticks(&self, s: Samples) -> i64 { + self.tempo.samples_to_ticks(s, self.sr) + } +} + +/// Split `clip` at absolute position `at` (strictly inside). Returns (left, right) where right gets `new_id`. +pub fn split_clip(clip: &Clip, at: Samples, new_id: ClipId, t: TimeCtx<'_>) -> Option<(Clip, Clip)> { + if at <= clip.start || at >= clip.end() { + return None; + } + let mut left = clip.clone(); + let mut right = clip.clone(); + right.id = new_id; + left.trim_end_to(at); + right.trim_start_to(at); + left.fade_out = Fade::default(); + right.fade_in = Fade::default(); + if let (ClipContent::Midi { sequence: ls }, ClipContent::Midi { sequence: rs }) = (&mut left.content, &mut right.content) { + let boundary = t.ticks(at) - t.ticks(clip.start); + ls.notes.retain(|n| n.start < boundary); + ls.ctrls.retain(|c| c.tick < boundary); + rs.notes.retain(|n| n.start >= boundary); + rs.ctrls.retain(|c| c.tick >= boundary); + for n in &mut rs.notes { + n.start -= boundary; + } + for c in &mut rs.ctrls { + c.tick -= boundary; + } + } + Some((left, right)) +} + +/// Trim a MIDI clip's start: drop notes before the new start and re-anchor the rest. +fn rebase_midi_start(clip: &mut Clip, old_start: Samples, t: TimeCtx<'_>) { + let delta = t.ticks(clip.start) - t.ticks(old_start); + if let ClipContent::Midi { sequence } = &mut clip.content { + if delta == 0 { + return; + } + sequence.notes.retain(|n| n.start >= delta); + sequence.ctrls.retain(|c| c.tick >= delta); + for n in &mut sequence.notes { + n.start -= delta; + } + for c in &mut sequence.ctrls { + c.tick -= delta; + } + } +} + +/// Split every clip on a track's active playlist that spans `at`. +pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize { + let tc_tempo = s.tempo.clone(); + let sr = s.sample_rate; + let t = TimeCtx { tempo: &tc_tempo, sr }; + let mut ids = Vec::new(); + let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end()).count()); + for _ in 0..n_split { + ids.push(s.new_clip_id()); + } + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; + let mut out = Vec::with_capacity(pl.clips.len() + ids.len()); + let mut count = 0; + for c in pl.clips.drain(..) { + if at > c.start + && at < c.end() + && let Some(id) = ids.pop() + && let Some((l, r)) = split_clip(&c, at, id, t) + { + out.push(l); + out.push(r); + count += 1; + continue; + } + out.push(c); + } + pl.clips = out; + pl.sort(); + count +} + +/// Remove material inside `range` on a track. In shuffle mode later clips move left. +/// Returns the removed material as clips positioned relative to `range.start`. +pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) -> Vec { + if range.is_empty() { + return Vec::new(); + } + separate_at(s, track, range.start); + separate_at(s, track, range.end); + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return Vec::new() }; + let mut removed = Vec::new(); + pl.clips.retain(|c| { + if c.start >= range.start && c.end() <= range.end { + let mut r = c.clone(); + r.start -= range.start; + removed.push(r); + false + } else { + true + } + }); + if shuffle { + let len = range.len(); + for c in &mut pl.clips { + if c.start >= range.end { + c.start -= len; + } + } + } + pl.sort(); + removed +} + +/// 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); + let Some(tr) = s.track(track) else { return Vec::new() }; + let mut out = Vec::new(); + for c in tr.clips() { + let Some(isect) = c.range().intersect(&range) else { continue }; + let mut part = c.clone(); + if isect.start > part.start { + let old = part.start; + part.trim_start_to(isect.start); + rebase_midi_start(&mut part, old, t); + } + if isect.end < part.end() { + part.trim_end_to(isect.end); + } + part.start -= range.start; + out.push(part); + } + out +} + +/// Insert `clips` (positions relative to 0) at `at`. Overwrites what's underneath unless +/// `shuffle`, which first pushes later material right by `length`. +pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Vec { + let length = length.max(clips.iter().map(Clip::end).max().unwrap_or(0)); + if shuffle { + separate_at(s, track, at); + if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { + for c in &mut pl.clips { + if c.start >= at { + c.start += length; + } + } + } + } + let mut ids = 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); + } + ids +} + +/// Place one clip, overwriting whatever it covers. +pub fn place_clip(s: &mut Session, track: TrackId, clip: Clip) { + clear_range(s, track, clip.range(), false); + if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { + pl.clips.push(clip); + pl.sort(); + } +} + +/// Insert silence: shift material at/after `range.start` right by the range length. +pub fn insert_silence(s: &mut Session, track: TrackId, range: Range) { + if range.is_empty() { + return; + } + separate_at(s, track, range.start); + if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { + for c in &mut pl.clips { + if c.start >= range.start { + c.start += range.len(); + } + } + } +} + +/// Trim clips to keep only material inside `range`. +pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize { + let tempo = s.tempo.clone(); + let tc = TimeCtx { tempo: &tempo, sr: s.sample_rate }; + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; + let mut n = 0; + for c in &mut pl.clips { + if let Some(i) = c.range().intersect(&range) + && i != c.range() + { + let old = c.start; + c.trim_start_to(i.start); + rebase_midi_start(c, old, tc); + c.trim_end_to(i.end); + n += 1; + } + } + n +} + +/// Trim the start of clips under `at` to `at` (Trim Start to Insertion). +pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize { + let tempo = s.tempo.clone(); + let tc = TimeCtx { tempo: &tempo, sr: s.sample_rate }; + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; + let mut n = 0; + for c in &mut pl.clips { + if c.range().contains(at) && at > c.start { + let old = c.start; + c.trim_start_to(at); + rebase_midi_start(c, old, tc); + n += 1; + } + } + n +} + +pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize { + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; + let mut n = 0; + for c in &mut pl.clips { + if at > c.start && at < c.end() { + c.trim_end_to(at); + n += 1; + } + } + n +} + +/// Heal separations inside `range`: merge neighbouring audio clips that continue the same file. +pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; + pl.sort(); + let mut out: Vec = Vec::with_capacity(pl.clips.len()); + let mut healed = 0; + for c in pl.clips.drain(..) { + if let Some(prev) = out.last_mut() { + let contiguous = prev.end() == c.start && c.start >= range.start && c.start <= range.end; + let same = match (&prev.content, &c.content) { + (ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => a == b && oa + prev.length == *ob, + _ => false, + }; + if contiguous && same && (prev.gain_db - c.gain_db).abs() < 1e-6 { + prev.length += c.length; + prev.fade_out = c.fade_out; + healed += 1; + continue; + } + } + out.push(c); + } + pl.clips = out; + healed +} + +/// Move clips by `delta` (and optionally to another track), overwriting what they land on. +pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Option) -> usize { + let mut moving: Vec<(TrackId, Clip)> = Vec::new(); + for t in &mut s.tracks { + let tid = t.id; + if let Some(pl) = t.playlist_mut() { + pl.clips.retain(|c| { + if ids.contains(&c.id) && !c.time_locked && !c.edit_locked { + moving.push((tid, c.clone())); + false + } else { + true + } + }); + } + } + let n = moving.len(); + for (tid, mut c) in moving { + c.start = c.start.saturating_add(delta).max(0); + place_clip(s, to_track.unwrap_or(tid), c); + } + n +} + +/// Create fades from a selection (the incumbent's Edit › Fades › Create semantics): +/// a range across a clip boundary makes a crossfade; at a clip start a fade-in; at its end a fade-out. +pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeShape) -> usize { + if range.is_empty() { + return 0; + } + let handles: Vec<(ClipId, Samples)> = { + let Some(tr) = s.track(track) else { return 0 }; + tr.clips() + .iter() + .map(|c| { + let avail = match c.content { + ClipContent::Audio { source, offset } => { + let total = s.source(source).map_or(0, |src| i64::try_from(src.frames).unwrap_or(i64::MAX)); + total.saturating_sub(offset).saturating_sub(c.length) + } + ClipContent::Midi { .. } => 0, + }; + (c.id, avail) + }) + .collect() + }; + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; + pl.sort(); + let mut made = 0; + let n = pl.clips.len(); + // Crossfades between adjacent clips whose boundary is inside the range. + for i in 0..n.saturating_sub(1) { + let (Some(a), Some(b)) = (pl.clips.get(i).cloned(), pl.clips.get(i + 1).cloned()) else { continue }; + let boundary = a.end(); + if b.start == boundary && range.start < boundary && range.end > boundary { + let before = boundary - range.start.max(a.start); + let after = range.end.min(b.end()) - boundary; + let a_handle = handles.iter().find(|h| h.0 == a.id).map_or(0, |h| h.1); + let b_handle = b.source_offset(); + // Extend each clip into the other's territory using its handles. + let ext_a = after.min(a_handle).max(0); + let ext_b = before.min(b_handle).max(0); + if let Some(ca) = pl.clips.get_mut(i) { + ca.length += ext_a; + ca.fade_out = Fade { len: (ext_a + before).min(ca.length), shape }; + } + if let Some(cb) = pl.clips.get_mut(i + 1) { + let old = cb.start; + cb.trim_start_to(old - ext_b); + cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape }; + } + made += 1; + } + } + for c in &mut pl.clips { + if range.start <= c.start && range.end > c.start && range.end < c.end() && c.fade_in.len == 0 { + c.fade_in = Fade { len: range.end - c.start, shape }; + made += 1; + } else if range.end >= c.end() && range.start > c.start && range.start < c.end() && c.fade_out.len == 0 { + c.fade_out = Fade { len: c.end() - range.start, shape }; + made += 1; + } + } + made +} + +/// Remove fades on clips touching `range`. +pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize { + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; + let mut n = 0; + for c in &mut pl.clips { + if c.range().overlaps(&range) || (range.is_empty() && c.range().contains(range.start)) { + if c.fade_in.len > 0 || c.fade_out.len > 0 { + n += 1; + } + c.fade_in = Fade::default(); + c.fade_out = Fade::default(); + } + } + // Overlapping (crossfaded) neighbours are trimmed back to the midpoint. + pl.sort(); + for i in 0..pl.clips.len().saturating_sub(1) { + let (Some(a_end), Some(b_start)) = (pl.clips.get(i).map(Clip::end), pl.clips.get(i + 1).map(|c| c.start)) else { continue }; + if a_end > b_start { + let mid = b_start + (a_end - b_start) / 2; + if let Some(a) = pl.clips.get_mut(i) { + a.trim_end_to(mid); + } + if let Some(b) = pl.clips.get_mut(i + 1) { + b.trim_start_to(mid); + } + } + } + n +} + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_model::{ChannelFormat, Source, SourceId, TrackKind}; + + fn session_with_clip() -> (Session, TrackId) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.sources.push(Source { + id: SourceId(500), + name: "src".into(), + path: "src.wav".into(), + channels: 1, + frames: 10_000, + sample_rate: 48_000, + format: soundcraft_audio_io::FileFormat::Wav, + time_reference: 0, + unsaved: false, + }); + let id = s.new_clip_id(); + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips.push(Clip::audio(id, "a", SourceId(500), 1000, 1000, 4000)); + } + (s, t) + } + + fn clips(s: &Session, t: TrackId) -> Vec<(Samples, Samples, Samples)> { + s.track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect() + } + + #[test] + fn separate_splits_and_heal_rejoins() { + let (mut s, t) = session_with_clip(); + assert_eq!(separate_at(&mut s, t, 3000), 1); + assert_eq!(clips(&s, t), vec![(1000, 2000, 1000), (3000, 2000, 3000)]); + assert_eq!(separate_at(&mut s, t, 1000), 0); + assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1); + assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]); + } + + #[test] + fn clear_slip_and_shuffle() { + let (mut s, t) = session_with_clip(); + let removed = clear_range(&mut s, t, Range::new(2000, 3000), false); + assert_eq!(removed.len(), 1); + assert_eq!(removed[0].start, 0); + assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (3000, 2000, 3000)]); + let (mut s, t) = session_with_clip(); + clear_range(&mut s, t, Range::new(2000, 3000), true); + assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 2000, 3000)]); + } + + #[test] + fn copy_paste_overwrites() { + let (mut s, t) = session_with_clip(); + let copied = copy_range(&s, t, Range::new(1500, 2500)); + assert_eq!(copied.len(), 1); + assert_eq!((copied[0].start, copied[0].length, copied[0].source_offset()), (0, 1000, 1500)); + paste(&mut s, t, 4000, &copied, 1000, false); + assert_eq!(clips(&s, t), vec![(1000, 3000, 1000), (4000, 1000, 1500)]); + } + + #[test] + fn paste_shuffle_ripples() { + let (mut s, t) = session_with_clip(); + let copied = copy_range(&s, t, Range::new(1000, 2000)); + paste(&mut s, t, 1000, &copied, 1000, true); + assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 4000, 1000)]); + } + + #[test] + fn insert_silence_and_trim() { + let (mut s, t) = session_with_clip(); + insert_silence(&mut s, t, Range::new(2000, 2500)); + assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2500, 3000, 2000)]); + let (mut s, t) = session_with_clip(); + trim_to(&mut s, t, Range::new(1500, 2000)); + assert_eq!(clips(&s, t), vec![(1500, 500, 1500)]); + } + + #[test] + fn fades_and_crossfades() { + let (mut s, t) = session_with_clip(); + assert_eq!(create_fades(&mut s, t, Range::new(500, 1500), FadeShape::Linear), 1); + assert_eq!(s.track(t).unwrap().clips()[0].fade_in.len, 500); + let (mut s, t) = session_with_clip(); + separate_at(&mut s, t, 3000); + assert_eq!(create_fades(&mut s, t, Range::new(2900, 3100), FadeShape::EqualPower), 1); + let c = s.track(t).unwrap().clips(); + assert_eq!(c[0].end(), 3100); + assert_eq!(c[1].start, 2900); + assert_eq!(c[0].fade_out.len, 200); + assert_eq!(c[1].fade_in.len, 200); + delete_fades(&mut s, t, Range::new(2900, 3100)); + let c = s.track(t).unwrap().clips(); + assert_eq!((c[0].end(), c[1].start), (3000, 3000)); + } + + #[test] + fn moves_overwrite_and_respect_locks() { + let (mut s, t) = session_with_clip(); + let id = s.track(t).unwrap().clips()[0].id; + assert_eq!(move_clips(&mut s, &[id], 500, None), 1); + assert_eq!(clips(&s, t), vec![(1500, 4000, 1000)]); + s.find_clip_mut(id).unwrap().time_locked = true; + assert_eq!(move_clips(&mut s, &[id], 500, None), 0); + } +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs new file mode 100644 index 00000000..e81b3e04 --- /dev/null +++ b/crates/engine/src/inspect.rs @@ -0,0 +1,144 @@ +//! JSON views of the session for agents (so they can verify their work without screenshots). + +use crate::Engine; +use serde_json::{Value, json}; +use soundcraft_model::{Clip, ClipContent, ClipId, Track, TrackId}; +use soundcraft_time::{TimeFormat, format_position}; + +fn clip_json(e: &Engine, c: &Clip) -> Value { + let s = e.session(); + let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let mut v = json!({ + "id": c.id, "name": c.name, "start": c.start, "end": c.end(), "length": c.length, + "start_time": fmt(c.start), "gain_db": c.gain_db, "muted": c.muted, + "fade_in": c.fade_in.len, "fade_out": c.fade_out.len, "locked": c.edit_locked || c.time_locked, + }); + match &c.content { + ClipContent::Audio { source, offset } => { + v["source"] = json!(source); + v["offset"] = json!(offset); + } + ClipContent::Midi { sequence } => { + v["notes"] = json!(sequence.notes.len()); + } + } + if !c.gain_env.is_empty() { + v["gain_envelope"] = json!(c.gain_env); + } + v +} + +fn track_json(e: &Engine, t: &Track, full: bool) -> Value { + let inserts: Vec = t + .mixer + .inserts + .iter() + .enumerate() + .filter_map(|(i, x)| { + x.as_ref().map(|x| json!({"slot": i, "plugin": x.plugin, "bypass": x.bypass, "params": if full { json!(x.params) } else { Value::Null }})) + }) + .collect(); + let sends: Vec = t + .mixer + .sends + .iter() + .enumerate() + .filter_map(|(i, x)| { + x.as_ref().map(|x| json!({"slot": i, "target": x.target, "level_db": x.level_db, "pan": x.pan, "mute": x.mute, "pre_fader": x.pre_fader})) + }) + .collect(); + let mut v = json!({ + "id": t.id, "name": t.name, "kind": t.kind.id(), "format": t.format.label(), + "volume_db": t.mixer.volume_db, "pan": t.mixer.pan, "mute": t.mixer.mute, "solo": t.mixer.solo, + "record_arm": t.mixer.record_arm, "input": t.mixer.input, "output": t.mixer.output, + "automation_mode": t.mixer.automation_mode.label(), "inserts": inserts, "sends": sends, + "clips": t.clips().iter().map(|c| clip_json(e, c)).collect::>(), + "playlist": t.playlist().map(|p| p.name.clone()), "playlists": t.playlists.len(), + "hidden": t.hidden, "inactive": t.inactive, "view": t.view, "height": t.height.label(), + "automation": t.automation.iter().filter(|l| !l.points.is_empty()).map(|l| json!({"param": l.param.label(), "points": if full { json!(l.points) } else { json!(l.points.len()) }})).collect::>(), + }); + if let Some(i) = &t.instrument { + v["instrument"] = json!(i.plugin); + } + if !t.comments.is_empty() { + v["comments"] = json!(t.comments); + } + v +} + +pub fn session(e: &Engine, full: bool) -> Value { + let s = e.session(); + json!({ + "name": s.name, "path": e.path, "dirty": e.is_dirty(), "sample_rate": s.sample_rate.hz(), "bit_depth": s.bit_depth, + "frame_rate": s.frame_rate.label(), "tempo": s.tempo.tempos(), "meter": s.tempo.meters(), + "length_samples": s.content_end(), + "tracks": s.tracks.iter().map(|t| track_json(e, t, full)).collect::>(), + "busses": s.busses.iter().map(|b| json!({"id": b.id, "name": b.name, "format": b.format.label()})).collect::>(), + "markers": s.markers.iter().map(|m| json!({"id": m.id, "number": m.number, "name": m.name, "kind": m.kind, "start": m.start, "end": m.end})).collect::>(), + "groups": s.groups.iter().map(|g| json!({"id": g.id, "name": g.name, "letter": g.letter, "members": g.members, "active": g.active})).collect::>(), + "sources": s.sources.iter().map(|x| json!({"id": x.id, "name": x.name, "channels": x.channels, "frames": x.frames, "path": x.path})).collect::>(), + "selection": {"start": s.edit.selection.start, "end": s.edit.selection.end, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips}, + "edit_mode": s.edit.edit_mode, "tool": s.edit.tool, "grid": s.edit.grid.label(), "nudge": s.edit.nudge.label(), + "transport": e.transport, + "undo": e.undo_label(), "redo": e.redo_label(), + }) +} + +pub fn track(e: &Engine, t: TrackId) -> Option { + e.session().track(t).map(|t| track_json(e, t, true)) +} + +pub fn clip(e: &Engine, id: ClipId) -> Option { + e.session().find_clip(id).map(|(t, c)| { + let mut v = clip_json(e, c); + v["track"] = json!(t); + if let ClipContent::Midi { sequence } = &c.content { + v["note_list"] = json!(sequence.notes); + } + v + }) +} + +/// Plain-text session report (File › Export › Session Info as Text). +pub fn session_text(e: &Engine) -> String { + let s = e.session(); + let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let mut out = String::new(); + 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())); + 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)); + } + out.push_str("\nT R A C K L I S T I N G\n"); + for t in &s.tracks { + out.push_str(&format!( + "TRACK NAME:\t{}\nCOMMENTS:\t{}\nSTATE:\t{}{}{}\n", + t.name, + t.comments, + if t.inactive { "Inactive " } else { "" }, + if t.mixer.mute { "Muted " } else { "" }, + if t.mixer.solo { "Solo" } else { "" } + )); + out.push_str("CHANNEL\tEVENT\tCLIP NAME\tSTART TIME\tEND TIME\tDURATION\tSTATE\n"); + for (i, c) in t.clips().iter().enumerate() { + out.push_str(&format!( + "1\t{}\t{}\t{}\t{}\t{}\t{}\n", + i + 1, + c.name, + fmt(c.start), + fmt(c.end()), + fmt(c.length), + if c.muted { "Muted" } else { "Unmuted" } + )); + } + out.push('\n'); + } + out.push_str("M A R K E R S L I S T I N G\n#\tLOCATION\tNAME\n"); + for m in &s.markers { + out.push_str(&format!("{}\t{}\t{}\n", m.number, fmt(m.start), m.name)); + } + out +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs new file mode 100644 index 00000000..cd3e1144 --- /dev/null +++ b/crates/engine/src/io.rs @@ -0,0 +1,378 @@ +//! Import, export, save/load, rendering helpers (bounce, consolidate, commit) and analysis. + +use crate::{Engine, EngineError, Result}; +use serde_json::{Value, json}; +use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat}; +use soundcraft_model::{ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; +use soundcraft_time::{Range, Samples}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Largest import we accept (samples per channel × channels), ~4 GiB of f32. +const MAX_IMPORT_SAMPLES: usize = 1 << 30; + +/// Register decoded audio as a new source. Resamples to the session rate if needed. +pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Option<&str>, format: FileFormat) -> SourceId { + let sr = s.sample_rate.hz(); + if buf.sample_rate != sr && buf.sample_rate > 0 { + buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr); + buf.sample_rate = sr; + } + let id = SourceId(s.alloc()); + let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string(); + s.sources.push(Source { + id, + name: stem.clone(), + path: path.map_or_else(|| format!("Audio Files/{stem}.wav"), str::to_string), + channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX), + frames: buf.frames() as u64, + sample_rate: sr, + format, + time_reference: 0, + unsaved: true, + }); + s.pool.insert(id, Arc::new(SourceAudio::new(buf))); + id +} + +/// Decode and place an audio file. With no target track, a new track of matching width is made. +pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option, at: Samples) -> Result { + let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); + let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; + if buf.frames().saturating_mul(buf.num_channels().max(1)) > MAX_IMPORT_SAMPLES { + return Err(EngineError::Io(format!("{name}: file is too large to import"))); + } + if buf.frames() == 0 { + return Err(EngineError::Io(format!("{name}: no audio"))); + } + let channels = buf.num_channels(); + let s = e.session_mut(); + let src = add_source(s, name, buf, path, info.format); + if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) { + sx.time_reference = bwf.time_reference; + } + let frames = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0)); + let track = match target { + Some(t) => t, + None => { + let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string(); + s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels.min(2)), Some(&stem)) + } + }; + let cid = s.new_clip_id(); + let stem = s.source(src).map(|x| x.name.clone()).unwrap_or_default(); + crate::edit::place_clip(s, track, Clip::audio(cid, stem, src, 0, at, frames)); + Ok(json!({"track": track, "clip": cid, "source": src, "frames": frames, "channels": channels})) +} + +/// 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 { + for (tick, bpm) in &smf.tempos { + let _ = s.tempo.set_tempo(*tick, *bpm); + } + for (tick, n, d) in &smf.meters { + let _ = s.tempo.set_meter(*tick, u32::from(*n), u32::from(*d)); + } + } + let base_tick = s.tempo.samples_to_ticks(at, s.sample_rate); + let mut out = Vec::new(); + for (i, tr) in smf.tracks.iter().enumerate() { + if tr.sequence.notes.is_empty() { + continue; + } + let name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() }; + let id = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some(&name)); + let drums = tr.sequence.notes.iter().all(|n| n.channel == 9); + if let Some(t) = s.track_mut(id) { + t.instrument = Some(soundcraft_model::Insert::new(if drums { "drum_synth" } else { "subtractive_synth" })); + } + let end_tick = base_tick + tr.sequence.end_tick().max(1); + let len = s.tempo.tick_to_samples(end_tick, s.sample_rate) - at; + let cid = s.new_clip_id(); + crate::edit::place_clip(s, id, Clip::midi(cid, name, at, len.max(1), tr.sequence.clone())); + out.push(id); + } + Ok(out) +} + +/// Export all MIDI clips as a format-1 SMF. +pub fn export_midi(e: &Engine) -> Result> { + let s = e.session(); + let mut smf = soundcraft_midi::Smf { + format: 1, + tracks: Vec::new(), + tempos: s.tempo.tempos().iter().map(|t| (t.tick, t.bpm)).collect(), + meters: s.tempo.meters().iter().map(|m| (m.tick, u8::try_from(m.numerator).unwrap_or(4), u8::try_from(m.denominator).unwrap_or(4))).collect(), + markers: s.markers.iter().map(|m| (s.tempo.samples_to_ticks(m.start, s.sample_rate), m.name.clone())).collect(), + key_sigs: Vec::new(), + }; + for t in s.tracks.iter().filter(|t| t.kind.is_midi()) { + let mut seq = soundcraft_midi::Sequence::default(); + 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 { + let mut n = *n; + n.start += base; + seq.notes.push(n); + } + } + } + seq.sort(); + smf.tracks.push(soundcraft_midi::SmfTrack { name: t.name.clone(), sequence: seq }); + } + soundcraft_midi::write_smf(&smf).map_err(|err| EngineError::Io(err.to_string())) +} + +/// Render the main mix. Returns the encoded file and (peak dBFS, integrated LUFS). +pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) -> Result<(Vec, (f32, f32))> { + let s = e.session(); + if r.len() > s.sample_rate.samples(4.0 * 3600.0) { + return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); + } + let mut ch = soundcraft_mix::render_range(s, r, 1024); + if normalize { + soundcraft_dsp::offline::normalize(&mut ch, -0.1, false); + } + let peak = soundcraft_dsp::offline::peak_db(&ch); + let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, ch.len()); + let frames = ch.first().map_or(0, Vec::len); + lm.process(&ch, frames); + let lufs = lm.integrated_lufs(); + let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch }; + let bytes = soundcraft_audio_io::encode(&buf, opts).map_err(|err| EngineError::Io(err.to_string()))?; + Ok((bytes, (peak, lufs))) +} + +/// Transient positions (absolute) in a track's clips within `r`. +pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec { + let r = if r.is_empty() { Range::new(0, s.content_end()) } else { r }; + let audio = soundcraft_mix::render_clips(s, t, r); + soundcraft_dsp::offline::detect_transients(&audio, s.sample_rate.as_f64() as f32, sens).into_iter().map(|i| r.start + i as i64).collect() +} + +/// Strip Silence: keep only the non-silent ranges of clips inside `r`. +pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> usize { + let audio = soundcraft_mix::render_clips(s, t, r); + let keep = soundcraft_dsp::offline::non_silent_ranges( + &audio, + thr, + usize::try_from(min_len).unwrap_or(0), + usize::try_from(pre).unwrap_or(0), + usize::try_from(post).unwrap_or(0), + ); + let mut cut_points = vec![r.start]; + for (a, b) in &keep { + cut_points.push(r.start + *a as i64); + cut_points.push(r.start + *b as i64); + } + cut_points.push(r.end); + for p in &cut_points { + crate::edit::separate_at(s, t, *p); + } + // Remove pieces that fall in the gaps. + let mut gaps = Vec::new(); + let mut prev = r.start; + for (a, b) in &keep { + let a = r.start + *a as i64; + if a > prev { + gaps.push(Range::new(prev, a)); + } + prev = r.start + *b as i64; + } + if prev < r.end { + gaps.push(Range::new(prev, r.end)); + } + if let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) { + pl.clips.retain(|c| !gaps.iter().any(|g| c.start >= g.start && c.end() <= g.end)); + } + keep.len() +} + +/// Consolidate the selection on a track into one new clip backed by a rendered source. +pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result { + let s = e.session(); + if s.track(t).is_none_or(|tr| !tr.kind.has_playlist() || tr.kind.is_midi()) { + return Ok(false); + } + let audio = soundcraft_mix::render_clips(s, t, r); + let name = format!("{}_consolidated", s.track(t).map_or("Audio", |x| x.name.as_str())); + let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; + let s = e.session_mut(); + let src = add_source(s, &name, buf, None, FileFormat::Wav); + crate::edit::clear_range(s, t, r, false); + let cid = s.new_clip_id(); + crate::edit::place_clip(s, t, Clip::audio(cid, name, src, 0, r.start, r.len())); + Ok(true) +} + +/// Render clip gain into a new source and reset the clip's gain. +pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result { + let s = e.session(); + let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { return Ok(false) }; + if !c.is_audio() { + return Ok(false); + } + let mut c2 = c.clone(); + c2.fade_in = Default::default(); + c2.fade_out = Default::default(); + let mut tmp = s.clone(); + if let Some(pl) = tmp.track_mut(t).and_then(|x| x.playlist_mut()) { + pl.clips = vec![c2]; + } + let audio = soundcraft_mix::render_clips(&tmp, t, c.range()); + let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; + let s = e.session_mut(); + let src = add_source(s, &format!("{}_gain", c.name), buf, None, FileFormat::Wav); + if let Some(cl) = s.find_clip_mut(id) { + cl.content = ClipContent::Audio { source: src, offset: 0 }; + cl.gain_db = 0.0; + cl.gain_env.clear(); + } + Ok(true) +} + +/// Commit a track's inserts into a new audio track (Track › Commit). +pub fn commit_track(e: &mut Engine, t: TrackId) -> Result> { + let s = e.session(); + let Some(tr) = s.track(t) else { return Ok(None) }; + if !tr.kind.is_audio_path() || tr.kind == TrackKind::Master { + return Ok(None); + } + let end = tr.clips().iter().map(Clip::end).max().unwrap_or(0); + if end <= 0 { + return Ok(None); + } + let range = Range::new(0, end + s.sample_rate.samples(2.0)); + let audio = soundcraft_mix::render_track_pre_fader(s, t, range); + let name = format!("{}.cm", tr.name); + let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2)); + let mixer = tr.mixer.clone(); + let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; + let s = e.session_mut(); + let src = add_source(s, &name, buf, None, FileFormat::Wav); + let nt = s.add_track(TrackKind::Audio, fmt, Some(&name)); + if let (Some(idx), Some(new_idx)) = (s.track_index(t), s.track_index(nt)) { + let tr = s.tracks.remove(new_idx); + s.tracks.insert((idx + 1).min(s.tracks.len()), tr); + } + let cid = s.new_clip_id(); + if let Some(ntr) = s.track_mut(nt) { + ntr.mixer.volume_db = mixer.volume_db; + ntr.mixer.output = mixer.output.clone(); + ntr.mixer.sends = mixer.sends.clone(); + if let Some(pl) = ntr.playlist_mut() { + pl.clips.push(Clip::audio(cid, name, src, 0, 0, range.len())); + } + } + if let Some(old) = s.track_mut(t) { + old.inactive = true; + old.hidden = true; + } + Ok(Some(nt)) +} + +pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result { + let s = e.session(); + std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?; + let mut n = 0; + for id in ids { + let Some((t, c)) = s.find_clip(*id) else { continue }; + let audio = soundcraft_mix::render_clips(s, t, c.range()); + let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; + let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?; + let path = Path::new(dir).join(format!("{}.wav", sanitize_name(&c.name))); + std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?; + n += 1; + } + Ok(n) +} + +pub fn sanitize_name(n: &str) -> String { + let s: String = n.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect(); + let s = s.trim().trim_start_matches('.').to_string(); + if s.is_empty() { "untitled".into() } else { s.chars().take(100).collect() } +} + +/// 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. +pub fn save_session(e: &mut Engine, path: &str) -> Result { + let p = PathBuf::from(path); + let p = if p.extension().is_none() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }; + let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); + let audio_dir = dir.join("Audio Files"); + let mut s = e.session().clone(); + let mut written = 0; + for src in &mut s.sources { + let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name)); + let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists(); + if exists_rel && !src.unsaved { + continue; + } + let Some(audio) = s.pool.get(src.id) else { continue }; + std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?; + let mut target = rel_target.clone(); + let mut k = 1; + while dir.join(&target).exists() && !(exists_rel && target == src.path) { + target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name)); + k += 1; + if k > 999 { + break; + } + } + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None }; + let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?; + std::fs::write(dir.join(&target), bytes).map_err(|err| EngineError::Io(format!("{target}: {err}")))?; + src.path = target; + src.unsaved = false; + src.format = FileFormat::Wav; + written += 1; + } + if let Some(stem) = p.file_stem().and_then(|x| x.to_str()) { + s.name = stem.to_string(); + } + let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + // Atomic-ish write: temp file then rename. + let tmp = p.with_extension("scraft.tmp"); + std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?; + std::fs::rename(&tmp, &p).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?; + // Keep the in-memory document in sync (paths, unsaved flags) without an undo step. + let fresh = s; + *e.session_mut() = fresh; + e.path = Some(p.to_string_lossy().into_owned()); + e.mark_clean(); + Ok(written) +} + +/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal). +pub fn open_session(e: &mut Engine, path: &str) -> Result> { + let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?; + let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); + let mut missing = Vec::new(); + for src in s.sources.clone() { + let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; + match std::fs::read(&p) { + Ok(bytes) => match soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())) { + Ok((_, mut buf)) => { + if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 { + buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz()); + buf.sample_rate = s.sample_rate.hz(); + } + s.pool.insert(src.id, Arc::new(SourceAudio::new(buf))); + } + Err(err) => missing.push(format!("{}: {err}", p.display())), + }, + Err(_) => missing.push(p.display().to_string()), + } + } + e.replace_session(s); + e.path = Some(path.to_string()); + for m in &missing { + e.message(format!("missing media: {m}")); + } + Ok(missing) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 00000000..0dadc9c8 --- /dev/null +++ b/crates/engine/src/lib.rs @@ -0,0 +1,281 @@ +//! The SoundCraft command engine. +//! +//! Every user-visible action is a registered command ([`cmd::CommandSpec`]): menus, shortcuts, the +//! CLI, the JSON control channel and MCP all dispatch the same commands by id with JSON params. +//! The engine owns the [`Session`] behind an `Arc` (copy-on-write), so undo snapshots are cheap. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod catalog; +pub mod cmd; +pub mod demo; +pub mod edit; +pub mod inspect; +pub mod io; + +use serde_json::{Value, json}; +use soundcraft_model::{Clip, Session, TrackId}; +use soundcraft_time::Samples; +use std::sync::Arc; + +pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; +pub use soundcraft_model as model; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum EngineError { + #[error("unknown command `{0}`")] + UnknownCommand(String), + #[error("`{0}` is not available: {1}")] + Disabled(String, String), + #[error("bad parameters for `{0}`: {1}")] + BadParams(String, String), + #[error("{0}")] + NotFound(String), + #[error("{0}")] + Io(String), + #[error("unsupported: {0}")] + Unsupported(String), + #[error("internal error in `{0}`: {1}")] + Internal(String, String), +} + +pub type Result = std::result::Result; + +/// Requests for the transport/audio engine, drained by the host app each frame. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub enum TransportRequest { + Play, + Stop, + TogglePlay, + Record, + Locate(Samples), + PlaySelection, + Pause, + HalfSpeed, + Scrub(Samples), + AllNotesOff, +} + +/// Snapshot of the transport reported back by the host (playback position etc.). +#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize)] +pub struct TransportStatus { + pub playing: bool, + pub recording: bool, + pub position: Samples, +} + +/// Clipboard contents: per-track clip copies relative to the copied range start. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Clipboard { + pub length: Samples, + pub tracks: Vec>, + pub automation: Vec>, +} + +#[derive(Debug, Clone)] +struct HistoryEntry { + label: String, + doc: Arc, +} + +/// Engine state: the document, history, clipboard and transport plumbing. +pub struct Engine { + doc: Arc, + undo: Vec, + redo: Vec, + pub history_limit: usize, + pub clipboard: Clipboard, + pub transport_requests: Vec, + pub transport: TransportStatus, + /// Command journal (id, params) for macro recording / agents. + pub journal: Vec<(String, Value)>, + /// Path of the session file, if saved. + pub path: Option, + /// Revision counter; bumps on every document change (UI caches key on it). + pub revision: u64, + dirty: bool, + /// Messages for the UI status line / agents. + pub messages: Vec, +} + +impl Default for Engine { + fn default() -> Self { + Engine::new(Session::default()) + } +} + +impl Engine { + pub fn new(session: Session) -> Self { + install_panic_hook(); + Engine { + doc: Arc::new(session), + undo: Vec::new(), + redo: Vec::new(), + history_limit: 200, + clipboard: Clipboard::default(), + transport_requests: Vec::new(), + transport: TransportStatus::default(), + journal: Vec::new(), + path: None, + revision: 1, + dirty: false, + messages: Vec::new(), + } + } + + pub fn session(&self) -> &Session { + &self.doc + } + + pub fn session_arc(&self) -> Arc { + Arc::clone(&self.doc) + } + + /// Mutable access (copy-on-write). Callers outside commands should prefer `execute`. + pub fn session_mut(&mut self) -> &mut Session { + self.revision = self.revision.wrapping_add(1); + self.dirty = true; + Arc::make_mut(&mut self.doc) + } + + /// Replace the whole document (open / new); clears history. + pub fn replace_session(&mut self, s: Session) { + self.doc = Arc::new(s); + self.undo.clear(); + self.redo.clear(); + self.revision = self.revision.wrapping_add(1); + self.dirty = false; + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + pub fn mark_clean(&mut self) { + self.dirty = false; + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + pub fn undo_label(&self) -> Option<&str> { + self.undo.last().map(|h| h.label.as_str()) + } + pub fn redo_label(&self) -> Option<&str> { + self.redo.last().map(|h| h.label.as_str()) + } + pub fn undo_history(&self) -> Vec { + self.undo.iter().map(|h| h.label.clone()).collect() + } + + pub fn undo(&mut self) -> bool { + let Some(h) = self.undo.pop() else { return false }; + let cur = std::mem::replace(&mut self.doc, h.doc); + self.redo.push(HistoryEntry { label: h.label, doc: cur }); + self.revision = self.revision.wrapping_add(1); + self.dirty = true; + true + } + + pub fn redo(&mut self) -> bool { + let Some(h) = self.redo.pop() else { return false }; + let cur = std::mem::replace(&mut self.doc, h.doc); + self.undo.push(HistoryEntry { label: h.label, doc: cur }); + self.revision = self.revision.wrapping_add(1); + self.dirty = true; + true + } + + /// Record an undo point manually (for interactive gestures committed by the UI). + pub fn push_undo(&mut self, label: &str, before: Arc) { + if Arc::ptr_eq(&before, &self.doc) { + return; + } + self.undo.push(HistoryEntry { label: label.to_string(), doc: before }); + self.redo.clear(); + if self.undo.len() > self.history_limit { + let excess = self.undo.len() - self.history_limit; + self.undo.drain(..excess); + } + } + + /// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the + /// document is restored. + pub fn execute(&mut self, id: &str, params: &Value) -> Result { + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if let Err(reason) = (spec.enabled)(self) { + return Err(EngineError::Disabled(id.to_string(), reason)); + } + let before = Arc::clone(&self.doc); + let rev = self.revision; + let dirty = self.dirty; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params))); + let result = match result { + Ok(r) => r, + Err(p) => { + let msg = + p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); + self.doc = before; + self.revision = rev.wrapping_add(1); + self.dirty = dirty; + return Err(EngineError::Internal(id.to_string(), msg)); + } + }; + match result { + Ok(v) => { + if spec.undoable && !Arc::ptr_eq(&before, &self.doc) { + self.push_undo(spec.label, before); + } + if spec.journal { + self.journal.push((id.to_string(), params.clone())); + } + Ok(v) + } + Err(e) => { + // Commands must not leave half-applied edits behind. + if !Arc::ptr_eq(&before, &self.doc) { + self.doc = before; + self.revision = self.revision.wrapping_add(1); + self.dirty = dirty; + } + Err(e) + } + } + } + + /// Convenience: execute with `{}` params. + pub fn run(&mut self, id: &str) -> Result { + self.execute(id, &json!({})) + } + + pub fn message(&mut self, m: impl Into) { + let m = m.into(); + log::info!("{m}"); + self.messages.push(m); + if self.messages.len() > 100 { + self.messages.remove(0); + } + } + + /// Tracks targeted by an edit: the selected tracks, or all tracks with playlists if none. + pub fn target_tracks(&self) -> Vec { + let s = self.session(); + if s.edit.selected_tracks.is_empty() { + Vec::new() + } else { + s.tracks.iter().filter(|t| s.edit.selected_tracks.contains(&t.id)).map(|t| t.id).collect() + } + } +} + +fn install_panic_hook() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + log::error!("panic: {info}"); + prev(info); + })); + }); +} diff --git a/crates/midi/Cargo.toml b/crates/midi/Cargo.toml new file mode 100644 index 00000000..c32093fc --- /dev/null +++ b/crates/midi/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "soundcraft-midi" +description = "SoundCraft MIDI: note/controller sequences, Standard MIDI File import/export, MIDI editing operations, GM names" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/midi/src/lib.rs b/crates/midi/src/lib.rs new file mode 100644 index 00000000..5ecb684c --- /dev/null +++ b/crates/midi/src/lib.rs @@ -0,0 +1,47 @@ +//! SoundCraft MIDI. +//! +//! - [`Note`], [`CtrlEvent`] and [`Sequence`]: the MIDI content of a region / track, in ticks at +//! [`PPQ`] (960) ticks per quarter note. +//! - [`read_smf`] / [`write_smf`]: Standard MIDI File import (format 0/1, format 2 best-effort) and +//! export (format 1, 960 PPQ). The reader treats its input as hostile: every length is checked, +//! variable-length quantities are bounded and event counts are capped. +//! - [`ops`]: MIDI editing operations (quantize, transpose, velocity, duration, split). +//! - [`note_name`], [`gm_drum_name`], [`gm_program_name`]: display names. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod names; +pub mod ops; +mod rng; +mod sequence; +mod smf; + +pub use names::{gm_drum_name, gm_program_name, note_name}; +pub use rng::XorShift; +pub use sequence::{CtrlEvent, CtrlKind, Note, Sequence}; +pub use smf::{MAX_EVENTS, MAX_TRACKS, Smf, SmfTrack, read_smf, write_smf}; + +/// Ticks per quarter note used throughout the model. +pub const PPQ: u32 = 960; + +/// Errors from reading or writing MIDI data. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum MidiError { + #[error("not a Standard MIDI File (missing MThd header)")] + NotSmf, + #[error("unexpected end of data at byte {0}")] + Truncated(usize), + #[error("variable-length quantity longer than 4 bytes at byte {0}")] + VlqTooLong(usize), + #[error("chunk length {len} at byte {offset} exceeds the remaining data")] + BadChunkLength { offset: usize, len: u64 }, + #[error("invalid MIDI data at byte {offset}: {reason}")] + Invalid { offset: usize, reason: &'static str }, + #[error("unsupported MIDI file: {0}")] + Unsupported(&'static str), + #[error("too many events (limit {0})")] + TooManyEvents(usize), + #[error("too many tracks (limit {0})")] + TooManyTracks(usize), + #[error("cannot write MIDI file: {0}")] + Write(String), +} diff --git a/crates/midi/src/names.rs b/crates/midi/src/names.rs new file mode 100644 index 00000000..b8acef65 --- /dev/null +++ b/crates/midi/src/names.rs @@ -0,0 +1,221 @@ +const PITCH_CLASSES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + +/// Note name using the Pro Tools convention where middle C (60) is "C3" (so 0 is "C-2"). +pub fn note_name(pitch: u8) -> String { + let class = PITCH_CLASSES.get(usize::from(pitch % 12)).copied().unwrap_or("?"); + let octave = i32::from(pitch / 12) - 2; + format!("{class}{octave}") +} + +const GM_DRUMS: [&str; 47] = [ + "Acoustic Bass Drum", + "Bass Drum 1", + "Side Stick", + "Acoustic Snare", + "Hand Clap", + "Electric Snare", + "Low Floor Tom", + "Closed Hi-Hat", + "High Floor Tom", + "Pedal Hi-Hat", + "Low Tom", + "Open Hi-Hat", + "Low-Mid Tom", + "Hi-Mid Tom", + "Crash Cymbal 1", + "High Tom", + "Ride Cymbal 1", + "Chinese Cymbal", + "Ride Bell", + "Tambourine", + "Splash Cymbal", + "Cowbell", + "Crash Cymbal 2", + "Vibraslap", + "Ride Cymbal 2", + "Hi Bongo", + "Low Bongo", + "Mute Hi Conga", + "Open Hi Conga", + "Low Conga", + "High Timbale", + "Low Timbale", + "High Agogo", + "Low Agogo", + "Cabasa", + "Maracas", + "Short Whistle", + "Long Whistle", + "Short Guiro", + "Long Guiro", + "Claves", + "Hi Wood Block", + "Low Wood Block", + "Mute Cuica", + "Open Cuica", + "Mute Triangle", + "Open Triangle", +]; + +/// General MIDI (level 1) percussion key-map name for a pitch on channel 10 (keys 35..=81). +pub fn gm_drum_name(pitch: u8) -> Option<&'static str> { + let idx = usize::from(pitch).checked_sub(35)?; + GM_DRUMS.get(idx).copied() +} + +const GM_PROGRAMS: [&str; 128] = [ + "Acoustic Grand Piano", + "Bright Acoustic Piano", + "Electric Grand Piano", + "Honky-tonk Piano", + "Electric Piano 1", + "Electric Piano 2", + "Harpsichord", + "Clavi", + "Celesta", + "Glockenspiel", + "Music Box", + "Vibraphone", + "Marimba", + "Xylophone", + "Tubular Bells", + "Dulcimer", + "Drawbar Organ", + "Percussive Organ", + "Rock Organ", + "Church Organ", + "Reed Organ", + "Accordion", + "Harmonica", + "Tango Accordion", + "Acoustic Guitar (nylon)", + "Acoustic Guitar (steel)", + "Electric Guitar (jazz)", + "Electric Guitar (clean)", + "Electric Guitar (muted)", + "Overdriven Guitar", + "Distortion Guitar", + "Guitar Harmonics", + "Acoustic Bass", + "Electric Bass (finger)", + "Electric Bass (pick)", + "Fretless Bass", + "Slap Bass 1", + "Slap Bass 2", + "Synth Bass 1", + "Synth Bass 2", + "Violin", + "Viola", + "Cello", + "Contrabass", + "Tremolo Strings", + "Pizzicato Strings", + "Orchestral Harp", + "Timpani", + "String Ensemble 1", + "String Ensemble 2", + "SynthStrings 1", + "SynthStrings 2", + "Choir Aahs", + "Voice Oohs", + "Synth Voice", + "Orchestra Hit", + "Trumpet", + "Trombone", + "Tuba", + "Muted Trumpet", + "French Horn", + "Brass Section", + "SynthBrass 1", + "SynthBrass 2", + "Soprano Sax", + "Alto Sax", + "Tenor Sax", + "Baritone Sax", + "Oboe", + "English Horn", + "Bassoon", + "Clarinet", + "Piccolo", + "Flute", + "Recorder", + "Pan Flute", + "Blown Bottle", + "Shakuhachi", + "Whistle", + "Ocarina", + "Lead 1 (square)", + "Lead 2 (sawtooth)", + "Lead 3 (calliope)", + "Lead 4 (chiff)", + "Lead 5 (charang)", + "Lead 6 (voice)", + "Lead 7 (fifths)", + "Lead 8 (bass + lead)", + "Pad 1 (new age)", + "Pad 2 (warm)", + "Pad 3 (polysynth)", + "Pad 4 (choir)", + "Pad 5 (bowed)", + "Pad 6 (metallic)", + "Pad 7 (halo)", + "Pad 8 (sweep)", + "FX 1 (rain)", + "FX 2 (soundtrack)", + "FX 3 (crystal)", + "FX 4 (atmosphere)", + "FX 5 (brightness)", + "FX 6 (goblins)", + "FX 7 (echoes)", + "FX 8 (sci-fi)", + "Sitar", + "Banjo", + "Shamisen", + "Koto", + "Kalimba", + "Bag pipe", + "Fiddle", + "Shanai", + "Tinkle Bell", + "Agogo", + "Steel Drums", + "Woodblock", + "Taiko Drum", + "Melodic Tom", + "Synth Drum", + "Reverse Cymbal", + "Guitar Fret Noise", + "Breath Noise", + "Seashore", + "Bird Tweet", + "Telephone Ring", + "Helicopter", + "Applause", + "Gunshot", +]; + +/// General MIDI (level 1) instrument name for a 0-based program number. Values above 127 +/// return `"Unknown"`. +pub fn gm_program_name(program: u8) -> &'static str { + GM_PROGRAMS.get(usize::from(program)).copied().unwrap_or("Unknown") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names() { + assert_eq!(note_name(60), "C3"); + assert_eq!(note_name(0), "C-2"); + assert_eq!(note_name(127), "G8"); + assert_eq!(note_name(61), "C#3"); + assert_eq!(gm_drum_name(36), Some("Bass Drum 1")); + assert_eq!(gm_drum_name(81), Some("Open Triangle")); + assert_eq!(gm_drum_name(34), None); + assert_eq!(gm_drum_name(82), None); + assert_eq!(gm_program_name(0), "Acoustic Grand Piano"); + assert_eq!(gm_program_name(127), "Gunshot"); + assert_eq!(gm_program_name(200), "Unknown"); + } +} diff --git a/crates/midi/src/ops.rs b/crates/midi/src/ops.rs new file mode 100644 index 00000000..16324462 --- /dev/null +++ b/crates/midi/src/ops.rs @@ -0,0 +1,258 @@ +//! MIDI editing operations (Event Operations): quantize, transpose, velocity, duration, split. +//! +//! All operations work in place on a slice of notes and never change the slice length, so they +//! can be applied to a selection. Randomised operations use a deterministic [`XorShift`] seeded +//! with [`DEFAULT_SEED`] (or an explicit seed via the `*_with_seed` variants). + +use crate::{Note, XorShift}; + +/// Seed used by [`quantize`] and [`change_velocity`] for their random components. +pub const DEFAULT_SEED: u64 = 0x5EED_50DC_4AF7_0001; + +fn unit(x: f32) -> f64 { + if x.is_finite() { f64::from(x).clamp(0.0, 1.0) } else { 0.0 } +} + +fn round_i64(x: f64) -> i64 { + if x.is_finite() { x.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64 } else { 0 } +} + +/// Options for [`quantize`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct QuantizeOptions { + /// Grid spacing in ticks (e.g. 240 = 1/16 note at 960 PPQ). Values <= 0 make quantize a no-op. + pub grid_ticks: i64, + /// How far notes move towards the grid line, 0..=1 (1 = all the way). + pub strength: f32, + /// Swing, 0..=1: every second grid line is delayed by `swing * grid / 3`, so 1.0 gives a + /// triplet feel. + pub swing: f32, + /// Only notes within this fraction of half a grid step of their target are moved + /// (1 = all notes). + pub include_within: f32, + /// Notes closer than this fraction of half a grid step to their target are left alone + /// (0 = none excluded). + pub exclude_within: f32, + /// Also quantize note ends to the grid. + pub quantize_ends: bool, + /// Keep each note's length when its start moves (otherwise the end stays put). Ignored when + /// `quantize_ends` is set. + pub preserve_duration: bool, + /// Random offset after quantizing, 0..=1 fraction of half a grid step. + pub random: f32, +} + +impl Default for QuantizeOptions { + fn default() -> Self { + Self { + grid_ticks: 240, + strength: 1.0, + swing: 0.0, + include_within: 1.0, + exclude_within: 0.0, + quantize_ends: false, + preserve_duration: true, + random: 0.0, + } + } +} + +/// The swung grid line nearest to `t`. +fn nearest_line(t: i64, grid: i64, swing: f64) -> i64 { + let swing_off = round_i64(swing * grid as f64 / 3.0); + let k = t.saturating_add(grid / 2).div_euclid(grid); + let mut best = i64::MAX; + let mut best_d = u64::MAX; + for kk in [k.saturating_sub(1), k, k.saturating_add(1)] { + let line = kk.saturating_mul(grid).saturating_add(if kk.rem_euclid(2) == 1 { swing_off } else { 0 }); + let d = line.abs_diff(t); + if d < best_d { + best_d = d; + best = line; + } + } + best +} + +/// Quantizes note starts (and optionally ends) to a grid. See [`QuantizeOptions`]. +pub fn quantize(notes: &mut [Note], opts: &QuantizeOptions) { + quantize_with_seed(notes, opts, DEFAULT_SEED); +} + +/// [`quantize`] with an explicit seed for the `random` component. +pub fn quantize_with_seed(notes: &mut [Note], opts: &QuantizeOptions, seed: u64) { + let grid = opts.grid_ticks; + if grid <= 0 { + return; + } + let strength = unit(opts.strength); + let swing = unit(opts.swing); + let include = unit(opts.include_within); + let exclude = unit(opts.exclude_within); + let random = unit(opts.random); + let half = grid as f64 / 2.0; + let mut rng = XorShift::new(seed); + + for n in notes.iter_mut() { + let old_start = n.start; + let old_end = n.end(); + let target = nearest_line(old_start, grid, swing); + let d = target.saturating_sub(old_start); + let dist = d.unsigned_abs() as f64; + if include < 1.0 && dist > include * half { + continue; + } + if dist < exclude * half { + continue; + } + let mut new_start = old_start.saturating_add(round_i64(d as f64 * strength)); + if random > 0.0 { + new_start = new_start.saturating_add(round_i64(rng.next_signed() * random * half)); + } + n.start = new_start; + n.length = if opts.quantize_ends { + let end_target = nearest_line(old_end, grid, swing); + let new_end = old_end.saturating_add(round_i64(end_target.saturating_sub(old_end) as f64 * strength)); + new_end.saturating_sub(new_start) + } else if opts.preserve_duration { + n.length + } else { + old_end.saturating_sub(new_start) + } + .max(1); + } +} + +/// Transposes notes by `semitones`. Pitches are **clamped** to 0..=127 (notes are never +/// dropped, so the slice length is unchanged); notes pushed past either end pile up on 0 or 127. +pub fn transpose(notes: &mut [Note], semitones: i32) { + for n in notes.iter_mut() { + n.pitch = i32::from(n.pitch).saturating_add(semitones).clamp(0, 127) as u8; + } +} + +/// Velocity change for [`change_velocity`]. Results are clamped to 1..=127. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum VelocityOp { + Set(u8), + Add(i32), + /// Multiply (non-finite factors leave velocities unchanged). + Scale(f32), + /// Clamp into `min..=max` (bounds are swapped if reversed). + Limit { + min: u8, + max: u8, + }, + /// Linear ramp over the notes' time span: the earliest note gets `from`, the latest `to`. + Ramp { + from: u8, + to: u8, + }, + /// Random offset of up to ± the amount. + Humanize(u8), +} + +fn clamp_vel(v: i64) -> u8 { + v.clamp(1, 127) as u8 +} + +/// Changes note-on velocities. See [`VelocityOp`]. +pub fn change_velocity(notes: &mut [Note], op: VelocityOp) { + change_velocity_with_seed(notes, op, DEFAULT_SEED); +} + +/// [`change_velocity`] with an explicit seed for [`VelocityOp::Humanize`]. +pub fn change_velocity_with_seed(notes: &mut [Note], op: VelocityOp, seed: u64) { + match op { + VelocityOp::Set(v) => notes.iter_mut().for_each(|n| n.velocity = clamp_vel(i64::from(v))), + VelocityOp::Add(d) => notes.iter_mut().for_each(|n| n.velocity = clamp_vel(i64::from(n.velocity) + i64::from(d))), + VelocityOp::Scale(f) => { + if f.is_finite() { + notes.iter_mut().for_each(|n| n.velocity = clamp_vel(round_i64(f64::from(n.velocity) * f64::from(f)))); + } + } + VelocityOp::Limit { min, max } => { + let (lo, hi) = if min <= max { (min, max) } else { (max, min) }; + notes.iter_mut().for_each(|n| n.velocity = clamp_vel(i64::from(n.velocity.clamp(lo, hi)))); + } + 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; + for n in notes.iter_mut() { + let t = if span > 0.0 { n.start.saturating_sub(first) as f64 / span } else { 0.0 }; + let v = f64::from(from) + (f64::from(to) - f64::from(from)) * t; + n.velocity = clamp_vel(round_i64(v)); + } + } + VelocityOp::Humanize(amount) => { + let mut rng = XorShift::new(seed); + for n in notes.iter_mut() { + let d = round_i64(rng.next_signed() * f64::from(amount)); + n.velocity = clamp_vel(i64::from(n.velocity) + d); + } + } + } +} + +/// Duration change for [`change_duration`]. Lengths are always kept >= 1 tick. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DurationOp { + Set(i64), + Add(i64), + /// Multiply (non-finite factors leave lengths unchanged). + Scale(f32), + /// Each note extends to `gap` ticks before the next later note start (any pitch). The last + /// note(s) keep their length. + Legato { + gap: i64, + }, + /// Shortens notes so they end no later than the next note of the same pitch and channel. + RemoveOverlap, +} + +/// Changes note lengths. See [`DurationOp`]. +pub fn change_duration(notes: &mut [Note], op: DurationOp) { + match op { + DurationOp::Set(l) => notes.iter_mut().for_each(|n| n.length = l.max(1)), + DurationOp::Add(d) => notes.iter_mut().for_each(|n| n.length = n.length.saturating_add(d).max(1)), + DurationOp::Scale(f) => { + if f.is_finite() { + notes.iter_mut().for_each(|n| n.length = round_i64(n.length as f64 * f64::from(f)).max(1)); + } + } + DurationOp::Legato { gap } => { + let mut starts: Vec = notes.iter().map(|n| n.start).collect(); + starts.sort_unstable(); + starts.dedup(); + for n in notes.iter_mut() { + let idx = starts.partition_point(|&s| s <= n.start); + if let Some(&next) = starts.get(idx) { + n.length = next.saturating_sub(gap).saturating_sub(n.start).max(1); + } + } + } + DurationOp::RemoveOverlap => { + let mut order: Vec = (0..notes.len()).collect(); + order.sort_by_key(|&i| notes.get(i).map(|n| (n.channel, n.pitch, n.start))); + for w in order.windows(2) { + let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + let Some(&nb) = notes.get(b) else { continue }; + if let Some(na) = notes.get_mut(a) + && na.channel == nb.channel + && na.pitch == nb.pitch + && na.end() > nb.start + { + na.length = nb.start.saturating_sub(na.start).max(1); + } + } + } + } +} + +/// Select/Split Notes by pitch: returns `(notes with pitch in pitch_from..=pitch_to, the rest)`, +/// both in input order. The bounds are swapped if reversed. +pub fn split_notes(notes: &[Note], pitch_from: u8, pitch_to: u8) -> (Vec, Vec) { + let (lo, hi) = if pitch_from <= pitch_to { (pitch_from, pitch_to) } else { (pitch_to, pitch_from) }; + notes.iter().partition(|n| (lo..=hi).contains(&n.pitch)) +} diff --git a/crates/midi/src/rng.rs b/crates/midi/src/rng.rs new file mode 100644 index 00000000..d4733784 --- /dev/null +++ b/crates/midi/src/rng.rs @@ -0,0 +1,28 @@ +/// Small deterministic PRNG (xorshift64*) used for randomize / humanize so results are +/// reproducible for a given seed. +#[derive(Debug, Clone)] +pub struct XorShift(u64); + +impl XorShift { + /// Creates a generator. A zero seed is replaced by a fixed non-zero constant. + pub fn new(seed: u64) -> Self { + Self(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed }) + } + + /// Next raw 64-bit value. + pub fn next_u64(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + + /// Uniform value in `[-1.0, 1.0)`. + pub fn next_signed(&mut self) -> f64 { + // 53 random bits -> [0,1) + let unit = (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64; + unit * 2.0 - 1.0 + } +} diff --git a/crates/midi/src/sequence.rs b/crates/midi/src/sequence.rs new file mode 100644 index 00000000..e50c6614 --- /dev/null +++ b/crates/midi/src/sequence.rs @@ -0,0 +1,125 @@ +use serde::{Deserialize, Serialize}; + +/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Note { + /// MIDI pitch 0..=127 (60 = middle C, shown as "C3"). + pub pitch: u8, + /// Note-on velocity 1..=127. + pub velocity: u8, + /// Note-off (release) velocity 0..=127; 64 when the source had none. + pub release_velocity: u8, + /// MIDI channel 0..=15. + pub channel: u8, + /// Start in ticks. + pub start: i64, + /// Length in ticks; always > 0. + pub length: i64, +} + +impl Note { + /// Tick at which the note ends (`start + length`, saturating). + pub fn end(&self) -> i64 { + self.start.saturating_add(self.length) + } +} + +/// Kind of a continuous / channel controller event. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum CtrlKind { + /// Control change with the controller number 0..=127. Value 0..=127. + Cc(u8), + /// Pitch bend. Value is centred: -8192..=8191 (0 = no bend). + PitchBend, + /// Channel (mono) aftertouch. Value 0..=127. + ChannelPressure, + /// Polyphonic aftertouch for the given pitch. Value 0..=127. + PolyPressure(u8), + /// Program change. Value is the program number 0..=127. + Program, +} + +/// A controller / channel event at a tick. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct CtrlEvent { + pub tick: i64, + pub channel: u8, + pub kind: CtrlKind, + pub value: i32, +} + +/// The MIDI content of a region or track. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Sequence { + /// Notes, sorted by `(start, pitch)` after [`Sequence::sort`]. + pub notes: Vec, + /// Controller events, sorted by tick after [`Sequence::sort`]. + pub ctrls: Vec, +} + +fn kind_key(k: CtrlKind) -> (u8, u8) { + match k { + CtrlKind::Cc(n) => (0, n), + CtrlKind::PitchBend => (1, 0), + CtrlKind::ChannelPressure => (2, 0), + CtrlKind::PolyPressure(p) => (3, p), + CtrlKind::Program => (4, 0), + } +} + +impl Sequence { + /// Sorts notes by `(start, pitch, channel, length, velocity)` and controller events by tick + /// (stable, so same-tick events keep their order). + pub fn sort(&mut self) { + self.notes.sort_by_key(|a| (a.start, a.pitch, a.channel, a.length, a.velocity)); + self.ctrls.sort_by_key(|c| c.tick); + } + + /// The last tick covered by any note end or controller event (0 when empty). + pub fn end_tick(&self) -> i64 { + let n = self.notes.iter().map(Note::end).max(); + let c = self.ctrls.iter().map(|c| c.tick).max(); + match (n, c) { + (Some(a), Some(b)) => a.max(b), + (Some(a), None) | (None, Some(a)) => a, + (None, None) => 0, + } + } + + /// Notes whose start lies in the half-open range `[start, end)`. + pub fn notes_in(&self, start: i64, end: i64) -> impl Iterator + '_ { + self.notes.iter().filter(move |n| n.start >= start && n.start < end) + } + + /// Notes that sound anywhere inside the half-open range `[start, end)`. + pub fn notes_overlapping(&self, start: i64, end: i64) -> impl Iterator + '_ { + self.notes.iter().filter(move |n| n.start < end && n.end() > start) + } + + /// Removes duplicate notes (same channel, pitch and start; the longest one is kept) and + /// duplicate controller events (same tick, channel and kind; the last one is kept, matching + /// what a synth would end up with). Returns how many events were removed. The sequence is + /// left sorted. + pub fn remove_duplicates(&mut self) -> usize { + let before = self.notes.len() + self.ctrls.len(); + // Longest first within each (start, pitch, channel) group, then keep the first. + self.notes.sort_by(|a, b| (a.start, a.pitch, a.channel, b.length).cmp(&(b.start, b.pitch, b.channel, a.length))); + self.notes.dedup_by(|later, kept| later.start == kept.start && later.pitch == kept.pitch && later.channel == kept.channel); + + self.ctrls.sort_by_key(|c| c.tick); + let mut kept: Vec = Vec::with_capacity(self.ctrls.len()); + for c in self.ctrls.drain(..) { + // Within a tick, replace an earlier event of the same channel+kind. + // Only events at the same tick (the tail of `kept`) can match. + let slot = + kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && kind_key(k.kind) == kind_key(c.kind)); + match slot { + Some(slot) => *slot = c, + None => kept.push(c), + } + } + self.ctrls = kept; + self.sort(); + before - (self.notes.len() + self.ctrls.len()) + } +} diff --git a/crates/midi/src/smf.rs b/crates/midi/src/smf.rs new file mode 100644 index 00000000..345d3e8a --- /dev/null +++ b/crates/midi/src/smf.rs @@ -0,0 +1,541 @@ +//! Standard MIDI File reading and writing (from the public SMF 1.0 specification). + +use std::collections::VecDeque; + +use crate::{CtrlEvent, CtrlKind, MidiError, Note, PPQ, Sequence}; + +/// Maximum number of events (across all tracks) [`read_smf`] will parse. +pub const MAX_EVENTS: usize = 5_000_000; +/// Maximum number of track chunks [`read_smf`] will accept. +pub const MAX_TRACKS: usize = 4096; + +/// Release velocity used when a note ends with a note-on of velocity 0 (per the spec, a +/// note-on with velocity 0 is a note-off with velocity 64). +const DEFAULT_RELEASE: u8 = 64; + +/// One track of a Standard MIDI File. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SmfTrack { + /// Name from the first Sequence/Track Name meta event (empty if none). + pub name: String, + pub sequence: Sequence, +} + +/// A Standard MIDI File in model terms. All ticks are at [`PPQ`] (960) per quarter note. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Smf { + /// Format of the source file (0, 1 or 2). [`write_smf`] always writes format 1. + pub format: u16, + pub tracks: Vec, + /// `(tick, beats per minute)`. + pub tempos: Vec<(i64, f64)>, + /// `(tick, numerator, denominator)`, e.g. `(0, 6, 8)`. + pub meters: Vec<(i64, u8, u8)>, + /// `(tick, text)` from Marker meta events. + pub markers: Vec<(i64, String)>, + /// `(tick, sharps (+) / flats (-) -7..=7, is_minor)`. + pub key_sigs: Vec<(i64, i8, bool)>, +} + +// --------------------------------------------------------------------------------------------- +// Reading +// --------------------------------------------------------------------------------------------- + +struct Cursor<'a> { + data: &'a [u8], + pos: usize, + /// Absolute offset of `data[0]` in the file, for error messages. + base: usize, +} + +impl<'a> Cursor<'a> { + fn at(&self) -> usize { + self.base.saturating_add(self.pos) + } + + fn is_empty(&self) -> bool { + self.pos >= self.data.len() + } + + fn u8(&mut self) -> Result { + let b = *self.data.get(self.pos).ok_or(MidiError::Truncated(self.at()))?; + self.pos += 1; + Ok(b) + } + + fn peek(&self) -> Result { + self.data.get(self.pos).copied().ok_or(MidiError::Truncated(self.at())) + } + + fn bytes(&mut self, n: usize) -> Result<&'a [u8], MidiError> { + let end = self.pos.checked_add(n).ok_or(MidiError::Truncated(self.at()))?; + let s = self.data.get(self.pos..end).ok_or(MidiError::Truncated(self.at()))?; + self.pos = end; + Ok(s) + } + + fn u16(&mut self) -> Result { + let b = self.bytes(2)?; + Ok(u16::from_be_bytes([b.first().copied().unwrap_or(0), b.get(1).copied().unwrap_or(0)])) + } + + fn u32(&mut self) -> Result { + let b = self.bytes(4)?; + let mut a = [0u8; 4]; + a.copy_from_slice(b); + Ok(u32::from_be_bytes(a)) + } + + /// Variable-length quantity: at most 4 bytes (28 bits) per the spec. + fn vlq(&mut self) -> Result { + let start = self.at(); + let mut v: u32 = 0; + for _ in 0..4 { + let b = self.u8()?; + v = (v << 7) | u32::from(b & 0x7F); + if b & 0x80 == 0 { + return Ok(v); + } + } + Err(MidiError::VlqTooLong(start)) + } +} + +/// Converts file ticks to model ticks: `model = file * num / den` (rounded). +#[derive(Clone, Copy)] +struct TickConv { + num: i128, + den: i128, +} + +impl TickConv { + fn from_division(division: u16, offset: usize) -> Result { + if division & 0x8000 == 0 { + if division == 0 { + return Err(MidiError::Invalid { offset, reason: "division of 0 ticks per quarter note" }); + } + Ok(Self { num: i128::from(PPQ), den: i128::from(division) }) + } else { + // SMPTE: high byte is negative frames/sec (two's complement), low byte ticks/frame. + // Approximated at 120 bpm: one quarter note = 0.5 s, so + // model = file / ticks_per_sec * 2 * PPQ. + let [hi, lo] = division.to_be_bytes(); + let fps = -i128::from(hi as i8); + let tpf = i128::from(lo); + if fps <= 0 || tpf == 0 { + return Err(MidiError::Invalid { offset, reason: "invalid SMPTE division" }); + } + let two_ppq = 2 * i128::from(PPQ); + if fps == 29 { + // 29.97 drop-frame: ticks/sec = 30000/1001 * tpf + Ok(Self { num: two_ppq * 1001, den: 30000 * tpf }) + } else { + Ok(Self { num: two_ppq, den: fps * tpf }) + } + } + } + + fn convert(&self, file_tick: u64) -> i64 { + let v = (i128::from(file_tick) * self.num + self.den / 2) / self.den; + i64::try_from(v).unwrap_or(i64::MAX) + } +} + +struct Globals { + tempos: Vec<(i64, f64)>, + meters: Vec<(i64, u8, u8)>, + markers: Vec<(i64, String)>, + key_sigs: Vec<(i64, i8, bool)>, +} + +/// Parses a Standard MIDI File. +/// +/// - Formats 0 and 1 are fully supported; format 2 is read best-effort (all tracks share one +/// timeline). Each `MTrk` chunk becomes one [`SmfTrack`]; for format 1 a leading conductor +/// track without notes or controllers is folded into the global tempo/meter/marker lists. +/// - Ticks are converted to [`PPQ`]; an SMPTE division is approximated at 120 bpm. +/// - Running status, note-on velocity 0 as note-off, unmatched note-offs (ignored), overlapping +/// same-pitch notes (matched first-in first-out) and hanging notes (closed at the end of the +/// track) are handled. Zero-length notes get a length of 1 tick. +/// - Unknown chunks and meta events are skipped. Any malformed structure (bad header, truncated +/// chunk, VLQ longer than 4 bytes, data byte without running status, ...) returns an error. +pub fn read_smf(bytes: &[u8]) -> Result { + let mut cur = Cursor { data: bytes, pos: 0, base: 0 }; + if cur.bytes(4).map_err(|_| MidiError::NotSmf)? != b"MThd" { + return Err(MidiError::NotSmf); + } + let header_len = cur.u32()?; + if header_len < 6 { + return Err(MidiError::Invalid { offset: 4, reason: "header chunk shorter than 6 bytes" }); + } + let header_len = usize::try_from(header_len).map_err(|_| MidiError::BadChunkLength { offset: 4, len: u64::from(header_len) })?; + let header = cur.bytes(header_len).map_err(|_| MidiError::BadChunkLength { offset: 4, len: u64::try_from(header_len).unwrap_or(u64::MAX) })?; + let mut hc = Cursor { data: header, pos: 0, base: 8 }; + let format = hc.u16()?; + let _ntrks = hc.u16()?; + let division = hc.u16()?; + if format > 2 { + return Err(MidiError::Unsupported("SMF format greater than 2")); + } + let conv = TickConv::from_division(division, 12)?; + + let mut globals = Globals { tempos: Vec::new(), meters: Vec::new(), markers: Vec::new(), key_sigs: Vec::new() }; + let mut tracks = Vec::new(); + let mut event_budget = MAX_EVENTS; + + while !cur.is_empty() { + let chunk_at = cur.at(); + let id = cur.bytes(4)?; + let len = cur.u32()?; + let remaining = bytes.len().saturating_sub(cur.pos); + let len_usize = usize::try_from(len).unwrap_or(usize::MAX); + if len_usize > remaining { + return Err(MidiError::BadChunkLength { offset: chunk_at, len: u64::from(len) }); + } + let body_at = cur.at(); + let body = cur.bytes(len_usize)?; + if id != b"MTrk" { + continue; // unknown chunk types must be ignored + } + if tracks.len() >= MAX_TRACKS { + return Err(MidiError::TooManyTracks(MAX_TRACKS)); + } + let track = read_track(body, body_at, conv, &mut globals, &mut event_budget)?; + tracks.push(track); + } + + if format == 1 && tracks.len() > 1 && tracks.first().is_some_and(|t| t.sequence.notes.is_empty() && t.sequence.ctrls.is_empty()) { + tracks.remove(0); + } + + // Keep global lists ordered by tick (stable: file order within a tick). + globals.tempos.sort_by_key(|t| t.0); + globals.meters.sort_by_key(|t| t.0); + globals.markers.sort_by_key(|t| t.0); + globals.key_sigs.sort_by_key(|t| t.0); + + Ok(Smf { format, tracks, tempos: globals.tempos, meters: globals.meters, markers: globals.markers, key_sigs: globals.key_sigs }) +} + +/// Open note-ons for one (channel, pitch), oldest first: `(model tick, velocity)`. +type OpenNotes = Vec>; + +fn close_note(open: &mut OpenNotes, notes: &mut Vec, channel: u8, pitch: u8, tick: i64, release: u8) { + let key = usize::from(channel & 0x0F) * 128 + usize::from(pitch & 0x7F); + let Some(stack) = open.get_mut(key) else { return }; + if stack.is_empty() { + return; // unmatched note-off: ignored + } + let Some((start, velocity)) = stack.pop_front() else { return }; + let length = tick.saturating_sub(start).max(1); + notes.push(Note { pitch, velocity, release_velocity: release, channel, start, length }); +} + +fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget: &mut usize) -> Result { + let mut cur = Cursor { data: body, pos: 0, base }; + let mut name: Option = None; + let mut seq = Sequence::default(); + let mut open: OpenNotes = vec![VecDeque::new(); 16 * 128]; + let mut file_tick: u64 = 0; + let mut tick: i64 = 0; + let mut running: Option = None; + + while !cur.is_empty() { + if *budget == 0 { + return Err(MidiError::TooManyEvents(MAX_EVENTS)); + } + *budget -= 1; + + let delta = cur.vlq()?; + file_tick = file_tick.saturating_add(u64::from(delta)); + tick = conv.convert(file_tick); + + let event_at = cur.at(); + let first = cur.peek()?; + let status = if first & 0x80 != 0 { + cur.pos += 1; + first + } else { + running.ok_or(MidiError::Invalid { offset: event_at, reason: "data byte without running status" })? + }; + + match status { + 0xFF => { + running = None; + let kind = cur.u8()?; + let len = cur.vlq()?; + let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?; + match kind { + 0x2F => break, // End of Track + 0x03 => { + if name.is_none() { + name = Some(String::from_utf8_lossy(data).into_owned()); + } + } + 0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())), + 0x51 => { + if let [a, b, c] = data { + let us = (u32::from(*a) << 16) | (u32::from(*b) << 8) | u32::from(*c); + if us > 0 { + g.tempos.push((tick, 60_000_000.0 / f64::from(us))); + } + } + } + 0x58 => { + if let [nn, dd, ..] = data + && *nn > 0 + && *dd <= 7 + { + g.meters.push((tick, *nn, 1u8 << *dd)); + } + } + 0x59 => { + if let [sf, mi, ..] = data { + let sf = *sf as i8; + if (-7..=7).contains(&sf) { + g.key_sigs.push((tick, sf, *mi == 1)); + } + } + } + _ => {} + } + } + 0xF0 | 0xF7 => { + running = None; + let len = cur.vlq()?; + cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?; + } + 0x80..=0xEF => { + running = Some(status); + let channel = status & 0x0F; + let d1 = cur.u8()?; + if d1 & 0x80 != 0 { + return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" }); + } + let needs_two = !matches!(status & 0xF0, 0xC0 | 0xD0); + let d2 = if needs_two { + let d = cur.u8()?; + if d & 0x80 != 0 { + return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" }); + } + d + } else { + 0 + }; + match status & 0xF0 { + 0x80 => close_note(&mut open, &mut seq.notes, channel, d1, tick, d2), + 0x90 if d2 == 0 => close_note(&mut open, &mut seq.notes, channel, d1, tick, DEFAULT_RELEASE), + 0x90 => { + if let Some(stack) = open.get_mut(usize::from(channel) * 128 + usize::from(d1)) { + stack.push_back((tick, d2)); + } + } + 0xA0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PolyPressure(d1), value: i32::from(d2) }), + 0xB0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Cc(d1), value: i32::from(d2) }), + 0xC0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Program, value: i32::from(d1) }), + 0xD0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::ChannelPressure, value: i32::from(d1) }), + _ => { + // 0xE0 pitch bend: LSB then MSB, centred on 8192. + let v = (i32::from(d2) << 7 | i32::from(d1)) - 8192; + seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PitchBend, value: v }); + } + } + } + _ => return Err(MidiError::Invalid { offset: event_at, reason: "system real-time/common status in a track" }), + } + } + + // Close hanging notes at the end of the track. + for (key, stack) in open.iter_mut().enumerate() { + let channel = u8::try_from(key / 128).unwrap_or(0); + let pitch = u8::try_from(key % 128).unwrap_or(0); + for (start, velocity) in stack.drain(..) { + let length = tick.saturating_sub(start).max(1); + seq.notes.push(Note { pitch, velocity, release_velocity: DEFAULT_RELEASE, channel, start, length }); + } + } + seq.sort(); + Ok(SmfTrack { name: name.unwrap_or_default(), sequence: seq }) +} + +// --------------------------------------------------------------------------------------------- +// Writing +// --------------------------------------------------------------------------------------------- + +const MAX_VLQ: u64 = 0x0FFF_FFFF; + +fn push_vlq(out: &mut Vec, v: u64) -> Result<(), MidiError> { + if v > MAX_VLQ { + return Err(MidiError::Write(format!("value {v} does not fit in a MIDI variable-length quantity"))); + } + let mut buf = [0u8; 4]; + let mut n = 0; + let mut x = v; + loop { + if let Some(slot) = buf.get_mut(n) { + *slot = (x & 0x7F) as u8; + } + n += 1; + x >>= 7; + if x == 0 || n == 4 { + break; + } + } + for i in (0..n).rev() { + let b = buf.get(i).copied().unwrap_or(0); + out.push(if i > 0 { b | 0x80 } else { b }); + } + Ok(()) +} + +/// An event ready to be serialised: `(tick, order, bytes)`. `order` breaks ties at one tick. +struct WEvent { + tick: u64, + order: u8, + bytes: Vec, +} + +fn clamp_tick(t: i64) -> u64 { + u64::try_from(t.max(0)).unwrap_or(0) +} + +fn meta(kind: u8, data: &[u8]) -> Result, MidiError> { + let mut b = vec![0xFF, kind]; + push_vlq(&mut b, u64::try_from(data.len()).unwrap_or(u64::MAX))?; + b.extend_from_slice(data); + Ok(b) +} + +fn write_track(out: &mut Vec, mut events: Vec) -> Result<(), MidiError> { + events.sort_by_key(|e| (e.tick, e.order)); + let mut body = Vec::new(); + let mut last_tick = 0u64; + let mut running: Option = None; + for e in &events { + push_vlq(&mut body, e.tick - last_tick)?; + last_tick = e.tick; + let status = e.bytes.first().copied().unwrap_or(0); + if (0x80..=0xEF).contains(&status) { + if running == Some(status) { + body.extend_from_slice(e.bytes.get(1..).unwrap_or(&[])); + } else { + body.extend_from_slice(&e.bytes); + } + running = Some(status); + } else { + running = None; + body.extend_from_slice(&e.bytes); + } + } + // End of Track. + body.extend_from_slice(&[0x00, 0xFF, 0x2F, 0x00]); + let len = u32::try_from(body.len()).map_err(|_| MidiError::Write("track larger than 4 GiB".into()))?; + out.extend_from_slice(b"MTrk"); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&body); + Ok(()) +} + +/// Writes a format-1 Standard MIDI File at [`PPQ`] (960) ticks per quarter note. +/// +/// Track 1 is a conductor track holding tempos, meters, key signatures and markers; every +/// [`SmfTrack`] follows as its own track. Negative ticks are clamped to 0, data values are +/// clamped to their MIDI ranges, and note-on velocity 0 is written as 1 (0 would mean +/// note-off). At each tick note-offs are written before controllers, then note-ons. +/// +/// Errors: invalid tempo (non-finite or <= 0 bpm), meter denominator that is not a power of two +/// (1..=128), more than 65535 tracks, or a gap between events beyond the 28-bit delta limit. +pub fn write_smf(smf: &Smf) -> Result, MidiError> { + let ntrks = u16::try_from(smf.tracks.len().saturating_add(1)).map_err(|_| MidiError::Write("more than 65535 tracks".into()))?; + let mut out = Vec::new(); + out.extend_from_slice(b"MThd"); + out.extend_from_slice(&6u32.to_be_bytes()); + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&ntrks.to_be_bytes()); + out.extend_from_slice(&u16::try_from(PPQ).unwrap_or(960).to_be_bytes()); + + // Conductor track. + let mut ev = Vec::new(); + for &(t, bpm) in &smf.tempos { + if !bpm.is_finite() || bpm <= 0.0 { + return Err(MidiError::Write(format!("invalid tempo {bpm} bpm"))); + } + let us = (60_000_000.0 / bpm).round().clamp(1.0, f64::from(0x00FF_FFFFu32)) as u32; + let [_, a, b, c] = us.to_be_bytes(); + ev.push(WEvent { tick: clamp_tick(t), order: 0, bytes: meta(0x51, &[a, b, c])? }); + } + for &(t, num, den) in &smf.meters { + if den == 0 || !den.is_power_of_two() { + return Err(MidiError::Write(format!("meter denominator {den} is not a power of two"))); + } + let dd = den.trailing_zeros() as u8; + ev.push(WEvent { tick: clamp_tick(t), order: 1, bytes: meta(0x58, &[num, dd, 24, 8])? }); + } + for &(t, sf, minor) in &smf.key_sigs { + let sf = sf.clamp(-7, 7); + ev.push(WEvent { tick: clamp_tick(t), order: 2, bytes: meta(0x59, &[sf as u8, u8::from(minor)])? }); + } + for (t, text) in &smf.markers { + ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? }); + } + write_track(&mut out, ev)?; + + for track in &smf.tracks { + let mut ev = Vec::new(); + if !track.name.is_empty() { + ev.push(WEvent { tick: 0, order: 0, bytes: meta(0x03, track.name.as_bytes())? }); + } + for n in &track.sequence.notes { + let ch = n.channel & 0x0F; + let pitch = n.pitch & 0x7F; + let start = clamp_tick(n.start); + let end = clamp_tick(n.end()).max(start.saturating_add(1)); + ev.push(WEvent { tick: start, order: 3, bytes: vec![0x90 | ch, pitch, n.velocity.clamp(1, 127)] }); + ev.push(WEvent { tick: end, order: 1, bytes: vec![0x80 | ch, pitch, n.release_velocity & 0x7F] }); + } + for c in &track.sequence.ctrls { + let ch = c.channel & 0x0F; + let v7 = c.value.clamp(0, 127) as u8; + let bytes = match c.kind { + CtrlKind::Cc(num) => vec![0xB0 | ch, num & 0x7F, v7], + CtrlKind::Program => vec![0xC0 | ch, v7], + CtrlKind::ChannelPressure => vec![0xD0 | ch, v7], + CtrlKind::PolyPressure(p) => vec![0xA0 | ch, p & 0x7F, v7], + CtrlKind::PitchBend => { + let v = (c.value.saturating_add(8192)).clamp(0, 16383); + vec![0xE0 | ch, (v & 0x7F) as u8, ((v >> 7) & 0x7F) as u8] + } + }; + ev.push(WEvent { tick: clamp_tick(c.tick), order: 2, bytes }); + } + write_track(&mut out, ev)?; + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vlq_round_trip() { + for v in [0u64, 0x40, 0x7F, 0x80, 0x2000, 0x3FFF, 0x4000, 0x1F_FFFF, 0x20_0000, MAX_VLQ] { + let mut b = Vec::new(); + push_vlq(&mut b, v).unwrap(); + let mut c = Cursor { data: &b, pos: 0, base: 0 }; + assert_eq!(u64::from(c.vlq().unwrap()), v); + assert!(c.is_empty()); + } + assert!(push_vlq(&mut Vec::new(), MAX_VLQ + 1).is_err()); + } + + #[test] + fn spec_vlq_examples() { + let mut b = Vec::new(); + push_vlq(&mut b, 0x0800_0000).unwrap(); + assert_eq!(b, [0xC0, 0x80, 0x80, 0x00]); + let mut b = Vec::new(); + push_vlq(&mut b, 0x2000).unwrap(); + assert_eq!(b, [0xC0, 0x00]); + } +} diff --git a/crates/midi/tests/ops.rs b/crates/midi/tests/ops.rs new file mode 100644 index 00000000..1aed370f --- /dev/null +++ b/crates/midi/tests/ops.rs @@ -0,0 +1,217 @@ +use soundcraft_midi::ops::*; +use soundcraft_midi::*; + +fn n(pitch: u8, start: i64, length: i64) -> Note { + Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start, length } +} + +fn starts(notes: &[Note]) -> Vec { + notes.iter().map(|n| n.start).collect() +} + +#[test] +fn quantize_full_strength() { + let mut notes = vec![n(60, 10, 200), n(60, 230, 100), n(60, 365, 50), n(60, -20, 40)]; + quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, ..Default::default() }); + assert_eq!(starts(¬es), vec![0, 240, 480, 0]); + // Durations preserved by default. + assert_eq!(notes.iter().map(|n| n.length).collect::>(), vec![200, 100, 50, 40]); +} + +#[test] +fn quantize_half_strength() { + let mut notes = vec![n(60, 20, 100), n(60, 220, 100)]; + quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, strength: 0.5, ..Default::default() }); + assert_eq!(starts(¬es), vec![10, 230]); +} + +#[test] +fn quantize_swing() { + // 100% swing on a 1/8 (480) grid moves odd lines by 480/3 = 160 (triplet feel). + let mut notes = vec![n(60, 0, 10), n(60, 470, 10), n(60, 650, 10), n(60, 950, 10)]; + quantize(&mut notes, &QuantizeOptions { grid_ticks: 480, swing: 1.0, ..Default::default() }); + assert_eq!(starts(¬es), vec![0, 640, 640, 960]); + let mut notes = vec![n(60, 480, 10)]; + quantize(&mut notes, &QuantizeOptions { grid_ticks: 480, swing: 0.5, ..Default::default() }); + assert_eq!(starts(¬es), vec![560]); +} + +#[test] +fn quantize_include_exclude() { + let opts = QuantizeOptions { grid_ticks: 240, include_within: 0.5, ..Default::default() }; + // Half grid = 120; include within 60 ticks. + let mut notes = vec![n(60, 50, 10), n(60, 100, 10)]; + quantize(&mut notes, &opts); + assert_eq!(starts(¬es), vec![0, 100]); + + let opts = QuantizeOptions { grid_ticks: 240, exclude_within: 0.25, ..Default::default() }; + // Exclude closer than 30 ticks. + let mut notes = vec![n(60, 20, 10), n(60, 50, 10)]; + quantize(&mut notes, &opts); + assert_eq!(starts(¬es), vec![20, 0]); +} + +#[test] +fn quantize_ends_and_duration() { + let mut notes = vec![n(60, 10, 220)]; + quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, quantize_ends: true, ..Default::default() }); + assert_eq!((notes[0].start, notes[0].length), (0, 240)); + + let mut notes = vec![n(60, 10, 220)]; + quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, preserve_duration: false, ..Default::default() }); + assert_eq!((notes[0].start, notes[0].length), (0, 230)); + + // Ends quantized onto the start line still keep a length of at least 1. + let mut notes = vec![n(60, 10, 5)]; + quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, quantize_ends: true, ..Default::default() }); + assert_eq!(notes[0].length, 1); +} + +#[test] +fn quantize_random_is_deterministic_and_bounded() { + let opts = QuantizeOptions { grid_ticks: 240, random: 0.5, ..Default::default() }; + let mut a: Vec = (0..50).map(|i| n(60, i * 240 + 7, 10)).collect(); + let mut b = a.clone(); + quantize(&mut a, &opts); + quantize(&mut b, &opts); + assert_eq!(a, b); + for (i, note) in a.iter().enumerate() { + assert!((note.start - i as i64 * 240).abs() <= 60); + } + let mut c: Vec = (0..50).map(|i| n(60, i * 240 + 7, 10)).collect(); + quantize_with_seed(&mut c, &opts, 42); + assert_ne!(a, c); +} + +#[test] +fn quantize_degenerate_options() { + let mut notes = vec![n(60, 17, 10)]; + quantize(&mut notes, &QuantizeOptions { grid_ticks: 0, ..Default::default() }); + quantize(&mut notes, &QuantizeOptions { grid_ticks: -5, ..Default::default() }); + assert_eq!(notes[0].start, 17); + quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, strength: f32::NAN, swing: f32::INFINITY, ..Default::default() }); + assert_eq!(notes[0].start, 17); + let mut extreme = vec![n(60, i64::MAX - 10, 1), n(60, i64::MIN + 10, 1)]; + quantize(&mut extreme, &QuantizeOptions { grid_ticks: i64::MAX, random: 1.0, ..Default::default() }); +} + +#[test] +fn transpose_clamps() { + let mut notes = vec![n(0, 0, 1), n(60, 0, 1), n(127, 0, 1)]; + transpose(&mut notes, 12); + assert_eq!(notes.iter().map(|n| n.pitch).collect::>(), vec![12, 72, 127]); + transpose(&mut notes, -100); + assert_eq!(notes.iter().map(|n| n.pitch).collect::>(), vec![0, 0, 27]); + transpose(&mut notes, i32::MAX); + assert!(notes.iter().all(|n| n.pitch == 127)); +} + +#[test] +fn velocity_ops() { + let base = vec![n(60, 0, 1), n(60, 100, 1), n(60, 200, 1)]; + let vels = |v: &[Note]| v.iter().map(|n| n.velocity).collect::>(); + + let mut v = base.clone(); + change_velocity(&mut v, VelocityOp::Set(0)); + assert_eq!(vels(&v), vec![1, 1, 1]); + change_velocity(&mut v, VelocityOp::Set(200)); + assert_eq!(vels(&v), vec![127, 127, 127]); + + let mut v = base.clone(); + change_velocity(&mut v, VelocityOp::Add(50)); + assert_eq!(vels(&v), vec![127; 3]); + change_velocity(&mut v, VelocityOp::Add(-500)); + assert_eq!(vels(&v), vec![1; 3]); + + let mut v = base.clone(); + change_velocity(&mut v, VelocityOp::Scale(0.5)); + assert_eq!(vels(&v), vec![50; 3]); + change_velocity(&mut v, VelocityOp::Scale(f32::NAN)); + assert_eq!(vels(&v), vec![50; 3]); + + let mut v = vec![n(60, 0, 1), n(60, 0, 1)]; + v[0].velocity = 10; + v[1].velocity = 120; + change_velocity(&mut v, VelocityOp::Limit { min: 100, max: 30 }); + assert_eq!(vels(&v), vec![30, 100]); + + let mut v = base.clone(); + change_velocity(&mut v, VelocityOp::Ramp { from: 20, to: 120 }); + assert_eq!(vels(&v), vec![20, 70, 120]); + + let mut a = base.clone(); + let mut b = base.clone(); + change_velocity(&mut a, VelocityOp::Humanize(10)); + change_velocity(&mut b, VelocityOp::Humanize(10)); + assert_eq!(a, b); + assert!(a.iter().all(|n| (90..=110).contains(&n.velocity))); +} + +#[test] +fn duration_ops() { + let lens = |v: &[Note]| v.iter().map(|n| n.length).collect::>(); + let base = vec![n(60, 0, 100), n(62, 480, 100), n(64, 480, 50), n(60, 960, 100)]; + + let mut v = base.clone(); + change_duration(&mut v, DurationOp::Set(0)); + assert_eq!(lens(&v), vec![1; 4]); + + let mut v = base.clone(); + change_duration(&mut v, DurationOp::Add(-75)); + assert_eq!(lens(&v), vec![25, 25, 1, 25]); + + let mut v = base.clone(); + change_duration(&mut v, DurationOp::Scale(2.0)); + assert_eq!(lens(&v), vec![200, 200, 100, 200]); + + let mut v = base.clone(); + change_duration(&mut v, DurationOp::Legato { gap: 10 }); + assert_eq!(lens(&v), vec![470, 470, 470, 100]); + + let mut v = vec![n(60, 0, 500), n(60, 200, 500), n(61, 100, 500), Note { channel: 1, ..n(60, 100, 500) }]; + change_duration(&mut v, DurationOp::RemoveOverlap); + assert_eq!(lens(&v), vec![200, 500, 500, 500]); +} + +#[test] +fn split_by_pitch() { + let notes = vec![n(36, 0, 1), n(60, 1, 1), n(72, 2, 1), n(48, 3, 1)]; + let (inside, outside) = split_notes(¬es, 60, 40); + assert_eq!(inside.iter().map(|n| n.pitch).collect::>(), vec![60, 48]); + assert_eq!(outside.iter().map(|n| n.pitch).collect::>(), vec![36, 72]); +} + +#[test] +fn sequence_helpers() { + let mut seq = Sequence { + notes: vec![n(64, 100, 50), n(60, 100, 10), n(60, 100, 30), n(60, 0, 10), Note { channel: 2, ..n(60, 0, 10) }], + ctrls: vec![ + CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(1), value: 1 }, + CtrlEvent { tick: 5, channel: 0, kind: CtrlKind::Cc(1), value: 2 }, + CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(1), value: 3 }, + CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(2), value: 4 }, + ], + }; + assert_eq!(seq.end_tick(), 500); + seq.sort(); + assert_eq!(starts(&seq.notes), vec![0, 0, 100, 100, 100]); + assert_eq!(seq.notes[2].pitch, 60); + assert_eq!(seq.notes_in(0, 100).count(), 2); + assert_eq!(seq.notes_overlapping(105, 106).count(), 3); + + let removed = seq.remove_duplicates(); + assert_eq!(removed, 2); + assert_eq!(seq.notes.len(), 4); + let kept = seq.notes.iter().find(|n| n.start == 100 && n.pitch == 60).unwrap(); + assert_eq!(kept.length, 30); + assert_eq!(seq.ctrls.iter().map(|c| c.value).collect::>(), vec![2, 3, 4]); + assert_eq!(seq.remove_duplicates(), 0); + assert_eq!(Sequence::default().end_tick(), 0); +} + +#[test] +fn sequence_serde_round_trip() { + let seq = Sequence { notes: vec![n(60, 0, 1)], ctrls: vec![CtrlEvent { tick: 1, channel: 0, kind: CtrlKind::PolyPressure(60), value: 9 }] }; + let json = serde_json::to_string(&seq).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), seq); +} diff --git a/crates/midi/tests/smf.rs b/crates/midi/tests/smf.rs new file mode 100644 index 00000000..5b89e344 --- /dev/null +++ b/crates/midi/tests/smf.rs @@ -0,0 +1,316 @@ +use proptest::prelude::*; +use soundcraft_midi::*; + +fn vlq(mut v: u32) -> Vec { + let mut out = vec![(v & 0x7F) as u8]; + v >>= 7; + while v > 0 { + out.insert(0, (v & 0x7F) as u8 | 0x80); + v >>= 7; + } + out +} + +fn header(format: u16, ntrks: u16, division: u16) -> Vec { + let mut b = b"MThd".to_vec(); + b.extend_from_slice(&6u32.to_be_bytes()); + b.extend_from_slice(&format.to_be_bytes()); + b.extend_from_slice(&ntrks.to_be_bytes()); + b.extend_from_slice(&division.to_be_bytes()); + b +} + +fn track(body: &[u8]) -> Vec { + let mut b = b"MTrk".to_vec(); + b.extend_from_slice(&(body.len() as u32).to_be_bytes()); + b.extend_from_slice(body); + b +} + +fn file(format: u16, division: u16, bodies: &[Vec]) -> Vec { + let mut b = header(format, bodies.len() as u16, division); + for body in bodies { + b.extend(track(body)); + } + b +} + +fn note(pitch: u8, start: i64, length: i64) -> Note { + Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start, length } +} + +#[test] +fn round_trip() { + let mut seq = Sequence { + notes: vec![ + note(60, 0, 480), + Note { pitch: 64, velocity: 90, release_velocity: 30, channel: 3, start: 480, length: 960 }, + note(67, 960, 1), + note(60, 1920, 240), + ], + ctrls: vec![ + CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::Program, value: 5 }, + CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 100 }, + CtrlEvent { tick: 20, channel: 1, kind: CtrlKind::PitchBend, value: -8192 }, + CtrlEvent { tick: 30, channel: 1, kind: CtrlKind::PitchBend, value: 8191 }, + CtrlEvent { tick: 40, channel: 1, kind: CtrlKind::PitchBend, value: 0 }, + CtrlEvent { tick: 50, channel: 2, kind: CtrlKind::ChannelPressure, value: 77 }, + CtrlEvent { tick: 60, channel: 2, kind: CtrlKind::PolyPressure(61), value: 12 }, + ], + }; + seq.sort(); + let smf = Smf { + format: 1, + tracks: vec![ + SmfTrack { name: "Piano".into(), sequence: seq.clone() }, + SmfTrack { name: "Bass ♪".into(), sequence: Sequence { notes: vec![note(36, 100, 50)], ctrls: vec![] } }, + ], + tempos: vec![(0, 120.0), (3840, 90.0)], + meters: vec![(0, 4, 4), (3840, 6, 8)], + markers: vec![(0, "Intro".into()), (7680, "Verse".into())], + key_sigs: vec![(0, -3, true), (3840, 2, false)], + }; + let bytes = write_smf(&smf).unwrap(); + let back = read_smf(&bytes).unwrap(); + assert_eq!(back.format, 1); + assert_eq!(back.tracks.len(), 2); + assert_eq!(back.tracks[0].name, "Piano"); + assert_eq!(back.tracks[0].sequence, seq); + assert_eq!(back.tracks[1].name, "Bass ♪"); + assert_eq!(back.tracks[1].sequence.notes, vec![note(36, 100, 50)]); + assert_eq!(back.tempos.len(), 2); + assert!((back.tempos[0].1 - 120.0).abs() < 1e-9); + assert!((back.tempos[1].1 - 90.0).abs() < 1e-3); + assert_eq!(back.tempos[1].0, 3840); + assert_eq!(back.meters, smf.meters); + assert_eq!(back.markers, smf.markers); + assert_eq!(back.key_sigs, smf.key_sigs); + // Writing again is byte-identical. + let mut again = back.clone(); + again.tempos = smf.tempos.clone(); + assert_eq!(write_smf(&again).unwrap(), bytes); +} + +#[test] +fn write_rejects_bad_values() { + let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() }; + assert!(write_smf(&bad_tempo).is_err()); + let nan_tempo = Smf { tempos: vec![(0, f64::NAN)], ..Default::default() }; + assert!(write_smf(&nan_tempo).is_err()); + let bad_meter = Smf { meters: vec![(0, 4, 3)], ..Default::default() }; + assert!(write_smf(&bad_meter).is_err()); + let huge_gap = Smf { + tracks: vec![SmfTrack { name: String::new(), sequence: Sequence { notes: vec![note(60, 0x1000_0000, 10)], ctrls: vec![] } }], + ..Default::default() + }; + assert!(write_smf(&huge_gap).is_err()); +} + +#[test] +fn division_conversion_480_to_960() { + // Note at tick 480 (one quarter at 480 PPQ), length 240. + let body = [vlq(480), vec![0x90, 60, 100], vlq(240), vec![0x80, 60, 0], vec![0, 0xFF, 0x2F, 0]].concat(); + let smf = read_smf(&file(0, 480, &[body])).unwrap(); + assert_eq!(smf.tracks.len(), 1); + assert_eq!(smf.tracks[0].sequence.notes, vec![Note { pitch: 60, velocity: 100, release_velocity: 0, channel: 0, start: 960, length: 480 }]); +} + +#[test] +fn division_conversion_96_and_smpte() { + let body = [vlq(96), vec![0x90, 60, 100], vlq(48), vec![0x80, 60, 64]].concat(); + let smf = read_smf(&file(0, 96, std::slice::from_ref(&body))).unwrap(); + assert_eq!(smf.tracks[0].sequence.notes[0].start, 960); + assert_eq!(smf.tracks[0].sequence.notes[0].length, 480); + + // SMPTE 25 fps, 40 ticks/frame = 1000 ticks/s; 96 ticks = 96 ms; at 120 bpm 0.5 s = 960 ticks. + let div = u16::from_be_bytes([(-25i8) as u8, 40]); + let body = [vlq(500), vec![0x90, 60, 100], vlq(250), vec![0x80, 60, 64]].concat(); + let smf = read_smf(&file(0, div, &[body])).unwrap(); + assert_eq!(smf.tracks[0].sequence.notes[0].start, 960); + assert_eq!(smf.tracks[0].sequence.notes[0].length, 480); +} + +#[test] +fn running_status_and_velocity_zero_note_off() { + // Note-on with running status for both on and off (velocity 0). + let body = [ + vec![0x00, 0x91, 60, 100], // on C3 ch1 + vec![0x00, 64, 90], // running: on E3 + vlq(960), + vec![60, 0], // running: note-on vel 0 = off C3 + vec![0x00, 64, 0], + vec![0x00, 0xB1, 7, 100], // CC7 + vec![0x10, 10, 64], // running CC10 + ] + .concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + let seq = &smf.tracks[0].sequence; + assert_eq!( + seq.notes, + vec![ + Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 1, start: 0, length: 960 }, + Note { pitch: 64, velocity: 90, release_velocity: 64, channel: 1, start: 0, length: 960 }, + ] + ); + assert_eq!( + seq.ctrls, + vec![ + CtrlEvent { tick: 960, channel: 1, kind: CtrlKind::Cc(7), value: 100 }, + CtrlEvent { tick: 976, channel: 1, kind: CtrlKind::Cc(10), value: 64 }, + ] + ); +} + +#[test] +fn meta_cancels_running_status() { + let body = [vec![0x00, 0x90, 60, 100], vec![0x00, 0xFF, 0x01, 0x01, b'x'], vec![0x00, 60, 0]].concat(); + assert!(read_smf(&file(0, 960, &[body])).is_err()); +} + +#[test] +fn unmatched_note_off_ignored_and_hanging_notes_closed() { + let body = [ + vec![0x00, 0x80, 50, 0], // unmatched off + vec![0x00, 0x90, 60, 100], // hanging + vlq(100), + vec![0x90, 62, 100], + vlq(100), + vec![0x80, 62, 10], + vlq(800), + vec![0xFF, 0x2F, 0x00], + ] + .concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + let notes = &smf.tracks[0].sequence.notes; + assert_eq!(notes.len(), 2); + assert_eq!(notes[0], Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 1000 }); + assert_eq!(notes[1], Note { pitch: 62, velocity: 100, release_velocity: 10, channel: 0, start: 100, length: 100 }); +} + +#[test] +fn overlapping_same_pitch_fifo_and_zero_length() { + let body = [ + vec![0x00, 0x90, 60, 100], + vec![0x10, 0x90, 60, 80], + vec![0x10, 0x80, 60, 0], + vec![0x10, 0x80, 60, 0], + vec![0x00, 0x90, 70, 1], + vec![0x00, 0x80, 70, 0], + ] + .concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + let notes = &smf.tracks[0].sequence.notes; + assert_eq!((notes[0].start, notes[0].length, notes[0].velocity), (0, 32, 100)); + assert_eq!((notes[1].start, notes[1].length, notes[1].velocity), (16, 32, 80)); + assert_eq!((notes[2].pitch, notes[2].length), (70, 1)); +} + +#[test] +fn format1_conductor_folded_and_unknown_chunks_skipped() { + let conductor = [ + vec![0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20], // 500000 us = 120 bpm + vec![0x00, 0xFF, 0x58, 0x04, 3, 2, 24, 8], // 3/4 + vec![0x00, 0xFF, 0x2F, 0x00], + ] + .concat(); + let t1 = [vec![0x00, 0xFF, 0x03, 0x03], b"Org".to_vec(), vec![0x00, 0x90, 60, 100, 0x60, 0x80, 60, 0]].concat(); + let mut bytes = header(1, 2, 960); + bytes.extend(track(&conductor)); + bytes.extend(b"XFIH\x00\x00\x00\x03abc"); + bytes.extend(track(&t1)); + let smf = read_smf(&bytes).unwrap(); + assert_eq!(smf.tracks.len(), 1); + assert_eq!(smf.tracks[0].name, "Org"); + assert_eq!(smf.tempos, vec![(0, 120.0)]); + assert_eq!(smf.meters, vec![(0, 3, 4)]); +} + +#[test] +fn sysex_skipped() { + let body = [vec![0x00, 0xF0, 0x03, 0x7E, 0x7F, 0xF7], vec![0x00, 0x90, 60, 100, 0x10, 0x80, 60, 0]].concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + assert_eq!(smf.tracks[0].sequence.notes.len(), 1); +} + +#[test] +fn malformed_inputs_are_errors() { + assert!(matches!(read_smf(b""), Err(MidiError::NotSmf))); + assert!(matches!(read_smf(b"RIFF\x00\x00\x00\x06"), Err(MidiError::NotSmf))); + // Header truncated. + assert!(read_smf(b"MThd\x00\x00\x00\x06\x00\x01").is_err()); + // Header length < 6. + assert!(read_smf(b"MThd\x00\x00\x00\x02\x00\x01").is_err()); + // Division 0. + assert!(read_smf(&header(0, 0, 0)).is_err()); + // Format 3. + assert!(read_smf(&header(3, 0, 96)).is_err()); + // Chunk length larger than the data. + let mut b = header(0, 1, 96); + b.extend(b"MTrk\xFF\xFF\xFF\xF0\x00\x90"); + assert!(matches!(read_smf(&b), Err(MidiError::BadChunkLength { .. }))); + // Truncated chunk header. + let mut b = header(0, 1, 96); + b.extend(b"MTr"); + assert!(read_smf(&b).is_err()); + // Huge VLQ (5 continuation bytes). + let body = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x7F, 0x90, 60, 100]; + assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::VlqTooLong(_)))); + // Meta length past the end of the track. + let body = vec![0x00, 0xFF, 0x03, 0x7F, b'a']; + assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::Truncated(_)))); + // Truncated channel message. + let body = vec![0x00, 0x90, 60]; + assert!(read_smf(&file(0, 96, &[body])).is_err()); + // Data byte with no running status. + let body = vec![0x00, 60, 100]; + assert!(read_smf(&file(0, 96, &[body])).is_err()); + // System common inside a track. + let body = vec![0x00, 0xF2, 0, 0]; + assert!(read_smf(&file(0, 96, &[body])).is_err()); + // Header only (no tracks) is fine. + assert_eq!(read_smf(&header(1, 0, 96)).unwrap().tracks.len(), 0); +} + +#[test] +fn event_cap_reached_is_error() { + // MAX_EVENTS + 1 one-byte running-status program changes (2 bytes each with delta). + let mut body = vec![0x00, 0xC0, 0x00]; + body.reserve(MAX_EVENTS * 2); + for _ in 0..MAX_EVENTS { + body.extend_from_slice(&[0x00, 0x01]); + } + assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::TooManyEvents(_)))); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn random_bytes_never_panic(data in proptest::collection::vec(any::(), 0..512)) { + let _ = read_smf(&data); + } + + #[test] + fn random_track_bodies_never_panic(body in proptest::collection::vec(any::(), 0..512), div in any::(), fmt in 0u16..4) { + let _ = read_smf(&file(fmt, div, &[body])); + } + + #[test] + fn written_files_read_back(notes in proptest::collection::vec((0u8..128, 1u8..128, 0u8..16, 0i64..100_000, 1i64..10_000), 0..64)) { + let mut seq = Sequence { + notes: notes.iter().map(|&(pitch, velocity, channel, start, length)| Note { pitch, velocity, release_velocity: 64, channel, start, length }).collect(), + ctrls: vec![], + }; + seq.remove_duplicates(); + let smf = Smf { format: 1, tracks: vec![SmfTrack { name: "t".into(), sequence: seq.clone() }], ..Default::default() }; + let back = read_smf(&write_smf(&smf).unwrap()).unwrap(); + // Overlapping same-pitch notes may re-pair, but count and starts survive. + let got = back.tracks.first().map(|t| t.sequence.notes.clone()).unwrap_or_default(); + prop_assert_eq!(got.len(), seq.notes.len()); + let mut a: Vec<_> = got.iter().map(|n| (n.start, n.pitch, n.channel)).collect(); + let mut b: Vec<_> = seq.notes.iter().map(|n| (n.start, n.pitch, n.channel)).collect(); + a.sort(); + b.sort(); + prop_assert_eq!(a, b); + } +} diff --git a/crates/mix/Cargo.toml b/crates/mix/Cargo.toml new file mode 100644 index 00000000..8884c8f9 --- /dev/null +++ b/crates/mix/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "soundcraft-mix" +description = "SoundCraft mix engine: renders a session's tracks, plugins, sends, busses and automation to audio" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +soundcraft-time.workspace = true +soundcraft-audio-io.workspace = true +soundcraft-midi.workspace = true +soundcraft-dsp.workspace = true +soundcraft-model.workspace = true +log.workspace = true + +[lints] +workspace = true diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs new file mode 100644 index 00000000..fd1e1470 --- /dev/null +++ b/crates/mix/src/lib.rs @@ -0,0 +1,809 @@ +//! The SoundCraft mix engine. +//! +//! [`MixEngine::render`] produces one block of the main stereo output for a session at a timeline +//! position: clips (with clip gain and fades) → trim/phase → inserts → pre-fader sends → fader and +//! mute (with automation) → pan → post-fader sends → busses → aux inputs → main → master faders. +//! The same code drives realtime playback and offline bounces, so what you hear is what you get. +//! Steady-state rendering does not allocate. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use soundcraft_dsp::pan::{PanLaw, gains}; +use soundcraft_dsp::{Plugin, db_to_gain}; +use soundcraft_model::{AutoParam, BusId, Clip, ClipContent, Route, Session, Track, TrackId, TrackKind}; +use soundcraft_time::{Range, Samples}; +use std::collections::HashMap; + +/// Main mix width. +pub const MAIN_CHANNELS: usize = 2; + +/// Peak meter values for one strip (linear, per channel, max over the last block). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct StripMeter { + pub peak: [f32; 2], + /// Gain reduction of the first dynamics insert, dB. + pub gain_reduction_db: f32, +} + +struct PluginSlot { + id: String, + plugin: Box, +} + +struct Strip { + buf: Vec>, + plugins: Vec>, + instrument: Option, + /// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek. + held: Vec, +} + +impl Strip { + fn new() -> Self { + Strip { buf: Vec::new(), plugins: Vec::new(), instrument: None, held: Vec::new() } + } +} + +/// Renders sessions. Keep one per playback stream; plugin state persists across blocks. +pub struct MixEngine { + sample_rate: f32, + max_block: usize, + strips: HashMap, + busses: HashMap>>, + main: Vec>, + scratch: Vec, + pub meters: HashMap, + pub main_meter: StripMeter, + pub pan_law: PanLaw, + /// Last rendered position end (to detect seeks). + last_end: Samples, +} + +impl MixEngine { + pub fn new(sample_rate: f32, max_block: usize) -> Self { + let max_block = max_block.clamp(16, 16_384); + MixEngine { + sample_rate: if sample_rate.is_finite() && sample_rate > 0.0 { sample_rate } else { 48_000.0 }, + max_block, + strips: HashMap::new(), + busses: HashMap::new(), + main: vec![vec![0.0; max_block]; MAIN_CHANNELS], + scratch: vec![0.0; max_block], + meters: HashMap::new(), + main_meter: StripMeter::default(), + pan_law: PanLaw::Minus3, + last_end: 0, + } + } + + pub fn max_block(&self) -> usize { + self.max_block + } + + /// Reset all plugin state (on stop / seek). + pub fn reset(&mut self) { + for s in self.strips.values_mut() { + for p in s.plugins.iter_mut().flatten() { + p.plugin.reset(); + } + if let Some(i) = &mut s.instrument { + i.plugin.all_notes_off(); + i.plugin.reset(); + } + s.held.clear(); + } + self.meters.clear(); + self.main_meter = StripMeter::default(); + } + + /// Bring plugin instances in line with the session (creates/destroys as needed). Allocates; + /// call outside the realtime thread when the document changes, or let `render` do it. + pub fn sync(&mut self, s: &Session) { + let sr = self.sample_rate; + let mb = self.max_block; + self.strips.retain(|id, _| s.track(*id).is_some()); + for t in &s.tracks { + let ch = strip_channels(t); + let strip = self.strips.entry(t.id).or_insert_with(Strip::new); + if strip.buf.len() != ch || strip.buf.first().map_or(0, Vec::len) != mb { + strip.buf = vec![vec![0.0; mb]; ch]; + } + strip.plugins.resize_with(t.mixer.inserts.len(), || None); + for (slot, ins) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()) { + match ins { + Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin) => { + *slot = soundcraft_dsp::create(&i.plugin).map(|mut p| { + p.prepare(sr, mb, ch); + PluginSlot { id: i.plugin.clone(), plugin: p } + }); + } + None => *slot = None, + _ => {} + } + } + let want = t.instrument.as_ref().map(|i| i.plugin.as_str()); + if strip.instrument.as_ref().map(|p| p.id.as_str()) != want { + strip.instrument = want.and_then(|id| { + soundcraft_dsp::create(id).map(|mut p| { + p.prepare(sr, mb, ch.max(2)); + PluginSlot { id: id.to_string(), plugin: p } + }) + }); + } + } + for b in &s.busses { + self.busses.entry(b.id).or_insert_with(|| vec![vec![0.0; mb]; MAIN_CHANNELS]); + } + self.busses.retain(|id, _| s.bus(*id).is_some()); + } + + /// 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); + if pos != self.last_end { + // Seek: silence held notes and ringing plugins. + for st in self.strips.values_mut() { + if let Some(i) = &mut st.instrument { + i.plugin.all_notes_off(); + } + st.held.clear(); + } + } + self.last_end = pos.saturating_add(frames as i64); + self.sync(s); + for b in self.busses.values_mut() { + for c in b.iter_mut() { + c.iter_mut().take(frames).for_each(|x| *x = 0.0); + } + } + 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 = processing_order(s); + for idx in order { + let Some(t) = s.tracks.get(idx) else { continue }; + if t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca { + continue; + } + self.render_track(s, t, pos, frames, any_solo); + } + // Master faders process the main mix. + for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) { + if let Some(strip) = self.strips.get_mut(&t.id) { + for (dst, src) in strip.buf.iter_mut().zip(self.main.iter()) { + if let (Some(d), Some(sr)) = (dst.get_mut(..frames), src.get(..frames)) { + d.copy_from_slice(sr); + } + } + for (i, ins) in t.mixer.inserts.iter().enumerate() { + if let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) + && ins.active + && !ins.bypass + { + apply_params(slot, ins, t, i, pos); + slot.plugin.process(&mut strip.buf, frames); + } + } + let v0 = volume_db_at(t, pos); + let v1 = volume_db_at(t, pos + frames as i64); + for (ch, src) in strip.buf.iter().enumerate().take(MAIN_CHANNELS) { + if let Some(m) = self.main.get_mut(ch) { + for i in 0..frames { + let g = lerp_gain(v0, v1, i, frames); + if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) { + *d = x * g; + } + } + } + } + let mut pk = [0.0f32; 2]; + for (c, p) in pk.iter_mut().enumerate() { + *p = self.main.get(c).map_or(0.0, |m| peak(m.get(..frames).unwrap_or(&[]))); + } + self.meters.insert(t.id, StripMeter { peak: pk, gain_reduction_db: 0.0 }); + } + } + let mut pk = [0.0f32; 2]; + for (c, o) in out.iter_mut().enumerate() { + let src = self.main.get(c.min(MAIN_CHANNELS - 1)); + for i in 0..frames { + let v = src.and_then(|m| m.get(i)).copied().unwrap_or(0.0); + if let Some(d) = o.get_mut(i) { + *d = if v.is_finite() { v } else { 0.0 }; + } + } + if let Some(p) = pk.get_mut(c) { + *p = peak(o.get(..frames).unwrap_or(&[])); + } + } + self.main_meter = StripMeter { peak: pk, gain_reduction_db: 0.0 }; + } + + fn render_track(&mut self, s: &Session, t: &Track, pos: Samples, frames: usize, any_solo: bool) { + let Some(strip) = self.strips.get_mut(&t.id) else { return }; + for c in strip.buf.iter_mut() { + c.iter_mut().take(frames).for_each(|x| *x = 0.0); + } + // 1. Source: clips, bus input or instrument. + match t.kind { + TrackKind::Audio => { + for clip in t.clips() { + if clip.muted || !clip.is_audio() { + continue; + } + render_clip(s, clip, pos, frames, &mut strip.buf, &mut self.scratch); + } + } + TrackKind::Aux | TrackKind::Folder => { + if let Route::Bus(b) = &t.mixer.input + && let Some(src) = self.busses.get(b) + { + let nch = strip.buf.len(); + for (ch, dst) in strip.buf.iter_mut().enumerate() { + for i in 0..frames { + let v = if nch == 1 { + 0.5 * (src.first().and_then(|c| c.get(i)).copied().unwrap_or(0.0) + + src.get(1).and_then(|c| c.get(i)).copied().unwrap_or(0.0)) + } else { + src.get(ch.min(1)).and_then(|c| c.get(i)).copied().unwrap_or(0.0) + }; + if let Some(d) = dst.get_mut(i) { + *d = v; + } + } + } + } + } + TrackKind::Instrument | TrackKind::Midi => { + if let Some(inst) = &mut strip.instrument { + schedule_notes(s, t, pos, frames, inst, &mut strip.held); + let mut tmp = std::mem::take(&mut strip.buf); + if tmp.len() >= 2 { + inst.plugin.process(&mut tmp, frames); + } else if let Some(c0) = tmp.first_mut() { + // Mono MIDI track: render the instrument in stereo into scratch and fold. + let mut st = [std::mem::take(c0), std::mem::take(&mut self.scratch)]; + inst.plugin.process(&mut st, frames); + let [a, b] = st; + *c0 = a; + self.scratch = b; + for (x, y) in c0.iter_mut().zip(self.scratch.iter()).take(frames) { + *x = 0.5 * (*x + *y); + } + } + strip.buf = tmp; + } + } + _ => {} + } + // 2. Trim + phase. + 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); + } + } + // 3. Inserts. + let mut gr = 0.0f32; + for (i, ins) in t.mixer.inserts.iter().enumerate() { + let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue }; + if !ins.active || ins.bypass { + continue; + } + apply_params(slot, ins, t, i, pos); + slot.plugin.process(&mut strip.buf, frames); + if gr == 0.0 { + gr = slot.plugin.gain_reduction_db(); + } + } + // 4. Mute (static, automation, solo). + let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5); + let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t); + let muted = t.mixer.mute || auto_mute || soloed_out; + // Pre-fader sends. + for (i, snd) in t.mixer.sends.iter().enumerate() { + let Some(snd) = snd else { continue }; + if !snd.pre_fader || snd.mute || muted { + continue; + } + let lvl = send_level(t, i, snd.level_db, pos); + mix_to_bus(&strip.buf, frames, &mut self.busses, &snd.target, db_to_gain(lvl), snd.pan, self.pan_law, &t.mixer.pan); + } + // 5. Fader. + let v0 = volume_db_at(t, pos); + let v1 = volume_db_at(t, pos + frames as i64); + let (g0, g1) = if muted { (-144.0, -144.0) } else { (v0, v1) }; + for c in strip.buf.iter_mut() { + for i in 0..frames { + if let Some(x) = c.get_mut(i) { + *x *= lerp_gain(g0, g1, i, frames); + } + } + } + // Meters (post-fader, pre-pan). + let mut pk = [0.0f32; 2]; + for (c, p) in pk.iter_mut().enumerate() { + let ch = c.min(strip.buf.len().saturating_sub(1)); + *p = strip.buf.get(ch).map_or(0.0, |b| peak(b.get(..frames).unwrap_or(&[]))); + } + self.meters.insert(t.id, StripMeter { peak: pk, gain_reduction_db: gr }); + // Post-fader sends. + for (i, snd) in t.mixer.sends.iter().enumerate() { + let Some(snd) = snd else { continue }; + if snd.pre_fader || snd.mute || muted { + continue; + } + let lvl = send_level(t, i, snd.level_db, pos); + mix_to_bus(&strip.buf, frames, &mut self.busses, &snd.target, db_to_gain(lvl), snd.pan, self.pan_law, &t.mixer.pan); + } + // 6. Pan to output. + let pans: [f32; 2] = [pan_at(t, 0, pos), pan_at(t, 1, pos)]; + match &t.mixer.output { + Route::Main => pan_into(&strip.buf, frames, &mut self.main, &pans, self.pan_law), + Route::Bus(b) => { + if let Some(dst) = self.busses.get_mut(b) { + pan_into(&strip.buf, frames, dst, &pans, self.pan_law); + } + } + _ => {} + } + } +} + +fn strip_channels(t: &Track) -> usize { + match t.kind { + TrackKind::Master => MAIN_CHANNELS, + TrackKind::Instrument => t.channels().max(2), + _ => t.channels().clamp(1, 16), + } +} + +/// Audio/instrument tracks first, then auxes ordered so bus producers come before consumers. +fn processing_order(s: &Session) -> Vec { + let mut order: Vec = + (0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| !matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect(); + let mut auxes: Vec = + (0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect(); + // Depth = how many aux hops feed this aux's input bus (bounded; cycles cap out). + let depth = |i: usize| -> usize { + let mut d = 0; + let mut cur = vec![i]; + for _ in 0..8 { + let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect(); + let feeders: Vec = auxes_feeding(s, &inputs); + if feeders.is_empty() { + break; + } + d += 1; + cur = feeders; + } + d + }; + auxes.sort_by_key(|&i| depth(i)); + order.extend(auxes); + order +} + +fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec { + s.tracks + .iter() + .enumerate() + .filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder)) + .filter(|(_, t)| { + inputs.iter().any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r))) + }) + .map(|(i, _)| i) + .collect() +} + +/// Implicit solo: an aux is not muted when a soloed track feeds its input bus. +fn receives_solo(s: &Session, t: &Track) -> bool { + let Route::Bus(b) = &t.mixer.input else { return false }; + s.tracks + .iter() + .any(|o| o.mixer.solo && (o.mixer.output == Route::Bus(*b) || o.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(*b)))) +} + +fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut [Vec], _scratch: &mut [f32]) { + let ClipContent::Audio { source, offset } = clip.content else { return }; + let block = Range { start: pos, end: pos.saturating_add(frames as i64) }; + let Some(isect) = clip.range().intersect(&block) else { return }; + let Some(audio) = s.pool.get(source) else { return }; + let nsrc = audio.buffer.channels.len(); + if nsrc == 0 { + return; + } + let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 }; + let i0 = usize::try_from(isect.start - pos).unwrap_or(0); + let i1 = usize::try_from(isect.end - pos).unwrap_or(0).min(frames); + // Gain is evaluated every 32 samples and interpolated (fades and envelopes stay smooth). + const STEP: usize = 32; + for (ch, dst) in buf.iter_mut().enumerate() { + let src = audio.buffer.channels.get(ch % nsrc).map(Vec::as_slice).unwrap_or(&[]); + let mut i = i0; + while i < i1 { + let seg_end = (i + STEP).min(i1); + let rel0 = pos + i as i64 - clip.start; + let rel1 = pos + seg_end as i64 - clip.start; + let g0 = clip.gain_at(rel0); + let g1 = clip.gain_at(rel1.min(clip.length)); + let n = (seg_end - i) as f32; + for k in i..seg_end { + let rel = pos + k as i64 - clip.start; + let v = if (stretch - 1.0).abs() < 1e-9 { + usize::try_from(offset + rel).ok().and_then(|x| src.get(x)).copied().unwrap_or(0.0) + } else { + let f = offset as f64 + rel as f64 / stretch; + let x0 = f.floor(); + let fr = (f - x0) as f32; + let a = if x0 >= 0.0 { src.get(x0 as usize).copied().unwrap_or(0.0) } else { 0.0 }; + let b = if x0 + 1.0 >= 0.0 { src.get(x0 as usize + 1).copied().unwrap_or(0.0) } else { 0.0 }; + a + (b - a) * fr + }; + let t = (k - i) as f32 / n; + if let Some(d) = dst.get_mut(k) { + *d += v * (g0 + (g1 - g0) * t); + } + } + i = seg_end; + } + } +} + +/// Send note on/offs for MIDI clips overlapping the block. +fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec) { + let sr = s.sample_rate; + let end = pos.saturating_add(frames as i64); + for clip in t.clips() { + if clip.muted { + continue; + } + let ClipContent::Midi { sequence } = &clip.content else { continue }; + if !clip.range().overlaps(&Range { start: pos, end }) { + continue; + } + let base = s.tempo.samples_to_ticks(clip.start, sr); + for n in &sequence.notes { + let on = s.tempo.tick_to_samples(base + n.start, sr); + let off = s.tempo.tick_to_samples(base + n.start + n.length, sr).min(clip.end()); + if on >= clip.end() { + continue; + } + if on >= pos && on < end { + inst.plugin.note_on(usize::try_from(on - pos).unwrap_or(0), n.pitch, n.velocity); + if !held.contains(&n.pitch) && held.len() < 128 { + held.push(n.pitch); + } + } + if off >= pos && off < end { + inst.plugin.note_off(usize::try_from(off - pos).unwrap_or(0), n.pitch); + held.retain(|p| *p != n.pitch); + } + } + } +} + +fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) { + for (k, v) in &ins.params { + let mut val = *v; + if t.mixer.automation_mode.reads() { + let slot_u8 = u8::try_from(i).unwrap_or(0); + if let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k)) + && !l.points.is_empty() + { + val = l.value_at(pos, val); + } + } + if slot.plugin.param(k) != Some(val) { + slot.plugin.set_param(k, val); + } + } +} + +fn volume_db_at(t: &Track, pos: Samples) -> f32 { + if t.mixer.automation_mode.reads() + && let Some(l) = t.lane(&AutoParam::Volume) + && !l.points.is_empty() + { + return l.value_at(pos, t.mixer.volume_db); + } + t.mixer.volume_db +} + +fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 { + let base = t.mixer.pan.get(idx).copied().unwrap_or(if idx == 0 { 0.0 } else { 1.0 }); + if t.mixer.automation_mode.reads() + && let Some(l) = t.lane(&AutoParam::Pan(u8::try_from(idx).unwrap_or(0))) + && !l.points.is_empty() + { + return l.value_at(pos, base); + } + base +} + +fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 { + if t.mixer.automation_mode.reads() + && let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0))) + && !l.points.is_empty() + { + return l.value_at(pos, base); + } + base +} + +fn lerp_gain(db0: f32, db1: f32, i: usize, n: usize) -> f32 { + let g0 = db_to_gain(db0); + let g1 = db_to_gain(db1); + if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) } +} + +fn peak(x: &[f32]) -> f32 { + x.iter().fold(0.0f32, |m, v| if v.is_finite() { m.max(v.abs()) } else { m }) +} + +/// Pan a strip (1, 2 or N channels) into a stereo destination. +fn pan_into(src: &[Vec], frames: usize, dst: &mut [Vec], pans: &[f32; 2], law: PanLaw) { + let (l, r) = dst.split_at_mut(1); + let (Some(dl), Some(dr)) = (l.first_mut(), r.first_mut()) else { return }; + match src.len() { + 0 => {} + 1 => { + let (gl, gr) = gains(pans[0], law); + if let Some(s0) = src.first() { + for i in 0..frames { + let v = s0.get(i).copied().unwrap_or(0.0); + if let (Some(a), Some(b)) = (dl.get_mut(i), dr.get_mut(i)) { + *a += v * gl; + *b += v * gr; + } + } + } + } + n => { + // Stereo: each side has its own panner. Wider formats fold extra channels to centre. + let (a_l, a_r) = gains(pans[0], PanLaw::Zero); + let (b_l, b_r) = gains(pans[1], PanLaw::Zero); + for i in 0..frames { + let x0 = src.first().and_then(|c| c.get(i)).copied().unwrap_or(0.0); + let x1 = src.get(1).and_then(|c| c.get(i)).copied().unwrap_or(0.0); + let mut c = 0.0; + for ch in src.iter().skip(2).take(n.saturating_sub(2)) { + c += ch.get(i).copied().unwrap_or(0.0) * std::f32::consts::FRAC_1_SQRT_2; + } + if let (Some(a), Some(b)) = (dl.get_mut(i), dr.get_mut(i)) { + *a += x0 * a_l + x1 * b_l + c; + *b += x0 * a_r + x1 * b_r + c; + } + } + } + } +} + +fn mix_to_bus( + src: &[Vec], + frames: usize, + busses: &mut HashMap>>, + target: &Route, + gain: f32, + send_pan: f32, + law: PanLaw, + track_pan: &[f32], +) { + let Route::Bus(b) = target else { return }; + let Some(dst) = busses.get_mut(b) else { return }; + let pans = if src.len() == 1 { [send_pan, 0.0] } else { [track_pan.first().copied().unwrap_or(-1.0), track_pan.get(1).copied().unwrap_or(1.0)] }; + if gain <= 0.0 { + return; + } + // Scale via a temporary pass over the destination: add gain*src. + let (l, r) = dst.split_at_mut(1); + let (Some(dl), Some(dr)) = (l.first_mut(), r.first_mut()) else { return }; + if src.len() == 1 { + let (gl, gr) = gains(pans[0], law); + if let Some(s0) = src.first() { + for i in 0..frames { + let v = s0.get(i).copied().unwrap_or(0.0) * gain; + if let (Some(a), Some(bb)) = (dl.get_mut(i), dr.get_mut(i)) { + *a += v * gl; + *bb += v * gr; + } + } + } + } else { + for i in 0..frames { + let x0 = src.first().and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain; + let x1 = src.get(1).and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain; + if let (Some(a), Some(bb)) = (dl.get_mut(i), dr.get_mut(i)) { + *a += x0; + *bb += x1; + } + } + } +} + +/// Offline render of the main mix over `range` (stereo, planar). +pub fn render_range(s: &Session, range: Range, block: usize) -> Vec> { + let len = usize::try_from(range.len().max(0)).unwrap_or(0); + let mut out = vec![vec![0.0f32; len]; MAIN_CHANNELS]; + let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let b = eng.max_block(); + let mut tmp = vec![vec![0.0f32; b]; MAIN_CHANNELS]; + let mut done = 0usize; + while done < len { + let n = (len - done).min(b); + eng.render(s, range.start + done as i64, n, &mut tmp); + for (o, t) in out.iter_mut().zip(tmp.iter()) { + if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) { + d.copy_from_slice(sr); + } + } + done += n; + } + out +} + +/// Render only one track's clips (no mixer processing) — Consolidate. +pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec> { + let Some(t) = s.track(track) else { return Vec::new() }; + let len = usize::try_from(range.len().max(0)).unwrap_or(0); + let mut out = vec![vec![0.0f32; len]; t.channels().max(1)]; + let mut scratch = Vec::new(); + for c in t.clips() { + if c.muted { + continue; + } + render_clip(s, c, range.start, len, &mut out, &mut scratch); + } + out +} + +/// Render one track through its inserts (pre-fader) — Commit / Freeze. +pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec> { + let mut solo = s.clone(); + for t in &mut solo.tracks { + if t.id == track { + t.mixer.volume_db = 0.0; + t.mixer.mute = false; + t.mixer.solo = false; + t.mixer.output = Route::Main; + t.mixer.pan = match t.channels() { + 1 => vec![0.0], + _ => vec![-1.0, 1.0], + }; + t.automation.retain(|l| matches!(l.param, AutoParam::Plugin { .. })); + t.mixer.sends.iter_mut().for_each(|x| *x = None); + } else { + t.inactive = true; + } + } + let mut out = render_range(&solo, range, 1024); + // Mono tracks: fold back to one channel (undo the centre pan law). + if s.track(track).is_some_and(|t| t.channels() == 1) + && let Some(l) = out.first().cloned() + { + let g = std::f32::consts::SQRT_2; + out = vec![l.iter().map(|x| x * g).collect()]; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_audio_io::AudioBuffer; + use soundcraft_model::{ChannelFormat, Insert, SourceAudio, SourceId}; + use std::sync::Arc; + + fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; frames]] }; + s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(buf))); + let id = s.new_clip_id(); + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips.push(Clip::audio(id, "dc", SourceId(900), 0, 0, frames as i64)); + } + (s, t) + } + + #[test] + fn mono_center_pan_is_minus_3db() { + let (s, _) = session_with_dc(0.5, 4800); + let out = render_range(&s, Range::new(0, 4800), 512); + let expect = 0.5 * std::f32::consts::FRAC_1_SQRT_2; + assert!((out[0][1000] - expect).abs() < 1e-3, "{}", out[0][1000]); + assert!((out[1][1000] - expect).abs() < 1e-3); + } + + #[test] + fn fader_mute_and_solo() { + let (mut s, t) = session_with_dc(0.5, 4800); + s.track_mut(t).unwrap().mixer.volume_db = -6.0; + let out = render_range(&s, Range::new(0, 4800), 512); + let expect = 0.5 * std::f32::consts::FRAC_1_SQRT_2 * db_to_gain(-6.0); + assert!((out[0][2000] - expect).abs() < 1e-3); + s.track_mut(t).unwrap().mixer.mute = true; + let out = render_range(&s, Range::new(0, 4800), 512); + assert!(out[0][2000].abs() < 1e-6); + // Soloing another track mutes this one. + s.track_mut(t).unwrap().mixer.mute = false; + let other = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.track_mut(other).unwrap().mixer.solo = true; + let out = render_range(&s, Range::new(0, 4800), 512); + assert!(out[0][2000].abs() < 1e-6); + } + + #[test] + fn hard_pans() { + let (mut s, t) = session_with_dc(0.5, 4800); + s.track_mut(t).unwrap().mixer.pan = vec![-1.0]; + let out = render_range(&s, Range::new(0, 4800), 256); + assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4); + } + + #[test] + fn fades_and_clip_gain_apply() { + let (mut s, t) = session_with_dc(1.0, 48_000); + { + let c = &mut s.track_mut(t).unwrap().playlist_mut().unwrap().clips[0]; + c.fade_in = soundcraft_model::Fade { len: 1000, shape: soundcraft_model::FadeShape::Linear }; + c.gain_db = -6.0; + } + let out = render_range(&s, Range::new(0, 2000), 512); + let g = std::f32::consts::FRAC_1_SQRT_2 * db_to_gain(-6.0); + assert!(out[0][0].abs() < 1e-3); + assert!((out[0][500] - 0.5 * g).abs() < 0.02, "{}", out[0][500]); + assert!((out[0][1500] - g).abs() < 1e-3); + } + + #[test] + fn sends_feed_aux_via_bus() { + let (mut s, t) = session_with_dc(0.5, 4800); + let bus = s.add_bus("Verb", ChannelFormat::Stereo); + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Verb")); + s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus); + { + let tr = s.track_mut(t).unwrap(); + tr.mixer.output = Route::None; + let mut snd = soundcraft_model::SendSlot::new(Route::Bus(bus)); + snd.level_db = 0.0; + tr.mixer.sends[0] = Some(snd); + } + let out = render_range(&s, Range::new(0, 4800), 512); + assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]); + } + + #[test] + fn volume_automation_ramps() { + let (mut s, t) = session_with_dc(0.5, 48_000); + { + let tr = s.track_mut(t).unwrap(); + let l = tr.lane_mut(&AutoParam::Volume); + l.set_point(0, 0.0); + l.set_point(24_000, -144.0); + } + let out = render_range(&s, Range::new(0, 48_000), 512); + assert!(out[0][100] > 0.3); + assert!(out[0][30_000].abs() < 1e-4); + } + + #[test] + fn inserts_process_and_survive_unknown_plugins() { + let (mut s, t) = session_with_dc(0.5, 4800); + s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("does-not-exist")); + let out = render_range(&s, Range::new(0, 4800), 512); + assert!(out[0][100] > 0.3); + let id = soundcraft_dsp::plugins().iter().find(|p| p.id.contains("gain")).map(|p| p.id).unwrap_or("gain"); + let mut ins = Insert::new(id); + ins.params.insert("gain".into(), -6.0); + s.track_mut(t).unwrap().mixer.inserts[0] = Some(ins); + let _ = render_range(&s, Range::new(0, 4800), 512); + } + + #[test] + fn empty_session_is_silent() { + let s = Session::default(); + let out = render_range(&s, Range::new(0, 1000), 128); + assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0))); + } +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml new file mode 100644 index 00000000..c948d938 --- /dev/null +++ b/crates/model/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "soundcraft-model" +description = "SoundCraft session document: tracks, playlists, clips, mixer, routing, automation, markers" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +soundcraft-time.workspace = true +soundcraft-audio-io.workspace = true +soundcraft-midi.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/model/src/automation.rs b/crates/model/src/automation.rs new file mode 100644 index 00000000..c4ee449b --- /dev/null +++ b/crates/model/src/automation.rs @@ -0,0 +1,298 @@ +//! Automation lanes and modes. + +use soundcraft_time::Samples; + +/// Automation modes (per track). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum AutomationMode { + Off, + #[default] + Read, + Touch, + Latch, + TouchLatch, + Write, + Trim, +} + +impl AutomationMode { + pub const ALL: [AutomationMode; 7] = [ + AutomationMode::Off, + AutomationMode::Read, + AutomationMode::Touch, + AutomationMode::Latch, + AutomationMode::TouchLatch, + AutomationMode::Write, + AutomationMode::Trim, + ]; + pub fn label(self) -> &'static str { + match self { + AutomationMode::Off => "off", + AutomationMode::Read => "read", + AutomationMode::Touch => "touch", + AutomationMode::Latch => "latch", + AutomationMode::TouchLatch => "touch/latch", + AutomationMode::Write => "write", + AutomationMode::Trim => "trim", + } + } + pub fn from_id(s: &str) -> Option { + let s = s.to_ascii_lowercase(); + AutomationMode::ALL.into_iter().find(|m| m.label() == s || m.label().replace('/', "_") == s || format!("{m:?}").to_ascii_lowercase() == s) + } + pub fn reads(self) -> bool { + !matches!(self, AutomationMode::Off) + } +} + +/// Which parameter an automation lane controls. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub enum AutoParam { + /// Fader level, dB. + Volume, + /// Pan, -1..1 (index = which panner of a stereo track: 0 left, 1 right). + Pan(u8), + /// Mute (0/1). + Mute, + /// Send level, dB. + SendLevel(u8), + SendPan(u8), + SendMute(u8), + /// Insert slot + parameter id. + Plugin { + slot: u8, + param: String, + }, +} + +impl AutoParam { + pub fn label(&self) -> String { + match self { + AutoParam::Volume => "volume".into(), + AutoParam::Pan(0) => "pan".into(), + AutoParam::Pan(i) => format!("pan {}", i + 1), + AutoParam::Mute => "mute".into(), + AutoParam::SendLevel(i) => format!("send {} level", slot_letter(*i)), + AutoParam::SendPan(i) => format!("send {} pan", slot_letter(*i)), + AutoParam::SendMute(i) => format!("send {} mute", slot_letter(*i)), + AutoParam::Plugin { slot, param } => format!("insert {} {param}", slot_letter(*slot)), + } + } + /// 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(); + match s.as_str() { + "volume" | "vol" => return Some(AutoParam::Volume), + "pan" | "pan1" => return Some(AutoParam::Pan(0)), + "pan2" => return Some(AutoParam::Pan(1)), + "mute" => return Some(AutoParam::Mute), + _ => {} + } + if let Some(rest) = s.strip_prefix("send_") { + let mut it = rest.splitn(2, '_'); + let slot = letter_slot(it.next()?)?; + return match it.next()? { + "level" => Some(AutoParam::SendLevel(slot)), + "pan" => Some(AutoParam::SendPan(slot)), + "mute" => Some(AutoParam::SendMute(slot)), + _ => None, + }; + } + if let Some(rest) = s.strip_prefix("plugin:") { + let (slot, param) = rest.split_once(':')?; + return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() }); + } + None + } + /// Value range and default. + pub fn range(&self) -> (f32, f32, f32) { + match self { + AutoParam::Volume | AutoParam::SendLevel(_) => (-144.0, 12.0, 0.0), + AutoParam::Pan(_) | AutoParam::SendPan(_) => (-1.0, 1.0, 0.0), + AutoParam::Mute | AutoParam::SendMute(_) => (0.0, 1.0, 0.0), + AutoParam::Plugin { .. } => (f32::MIN, f32::MAX, 0.0), + } + } +} + +pub fn slot_letter(i: u8) -> char { + char::from(b'A'.saturating_add(i.min(25))) +} + +fn letter_slot(s: &str) -> Option { + let c = s.chars().next()?.to_ascii_uppercase(); + if s.len() == 1 && c.is_ascii_uppercase() { Some(c as u8 - b'A') } else { s.parse().ok() } +} + +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct AutomationPoint { + pub at: Samples, + pub value: f32, +} + +/// A breakpoint automation lane. Points are kept sorted by time. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct AutomationLane { + pub param: AutoParam, + pub points: Vec, + #[serde(default)] + pub enabled: bool, +} + +impl AutomationLane { + pub fn new(param: AutoParam) -> Self { + AutomationLane { param, points: Vec::new(), enabled: true } + } + + /// Value at `at` (linear interpolation; `default` when empty). + pub fn value_at(&self, at: Samples, default: f32) -> f32 { + let pts = &self.points; + let Some(first) = pts.first() else { return default }; + if at <= first.at { + return first.value; + } + let idx = pts.partition_point(|p| p.at <= at); + match (pts.get(idx.wrapping_sub(1)), pts.get(idx)) { + (Some(a), Some(b)) if b.at > a.at => { + // Mute-like lanes step instead of ramping. + 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) + } + (Some(a), _) => a.value, + _ => default, + } + } + + /// Insert or replace a breakpoint. + pub fn set_point(&mut self, at: Samples, value: f32) { + let (lo, hi, _) = self.param.range(); + let value = if value.is_nan() { 0.0 } else { value.clamp(lo, hi) }; + match self.points.binary_search_by(|p| p.at.cmp(&at)) { + Ok(i) => { + if let Some(p) = self.points.get_mut(i) { + p.value = value; + } + } + Err(i) => self.points.insert(i, AutomationPoint { at, value }), + } + } + + /// Remove points in `[start, end)`. + pub fn clear_range(&mut self, start: Samples, end: Samples) -> usize { + let before = self.points.len(); + self.points.retain(|p| p.at < start || p.at >= end); + before - self.points.len() + } + + /// Write a constant value over a range, keeping the surrounding curve intact with anchor points. + pub fn write_range(&mut self, start: Samples, end: Samples, value: f32, default: f32) { + if end <= start { + return; + } + let before = self.value_at(start.saturating_sub(1), default); + let after = self.value_at(end, default); + self.clear_range(start, end.saturating_add(1)); + if start > 0 { + self.set_point(start - 1, before); + } + self.set_point(start, value); + self.set_point(end, value); + self.set_point(end.saturating_add(1), after); + } + + /// Thin: remove points whose removal changes the curve by less than `tolerance`. + pub fn thin(&mut self, tolerance: f32) -> usize { + if self.points.len() < 3 { + return 0; + } + let mut keep = vec![true; self.points.len()]; + let mut last = 0usize; + for i in 1..self.points.len() - 1 { + let (Some(a), Some(b), Some(c)) = (self.points.get(last), self.points.get(i), self.points.get(i + 1)) else { continue }; + let span = (c.at - a.at) as f32; + let interp = if span > 0.0 { a.value + (c.value - a.value) * ((b.at - a.at) as f32 / span) } else { a.value }; + if (interp - b.value).abs() <= tolerance { + if let Some(k) = keep.get_mut(i) { + *k = false; + } + } else { + last = i; + } + } + let before = self.points.len(); + let mut it = keep.iter(); + self.points.retain(|_| it.next().copied().unwrap_or(true)); + before - self.points.len() + } + + /// Shift points at or after `from` by `delta` (Shuffle edits, Insert Time). + pub fn shift_from(&mut self, from: Samples, delta: Samples) { + for p in &mut self.points { + if p.at >= from { + p.at = p.at.saturating_add(delta).max(0); + } + } + self.points.sort_by_key(|p| p.at); + self.points.dedup_by_key(|p| p.at); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interpolation_and_steps() { + let mut l = AutomationLane::new(AutoParam::Volume); + assert_eq!(l.value_at(10, -3.0), -3.0); + l.set_point(0, 0.0); + l.set_point(100, -10.0); + assert!((l.value_at(50, 0.0) + 5.0).abs() < 1e-6); + assert_eq!(l.value_at(1000, 0.0), -10.0); + let mut m = AutomationLane::new(AutoParam::Mute); + m.set_point(0, 0.0); + m.set_point(100, 1.0); + assert_eq!(m.value_at(99, 0.0), 0.0); + assert_eq!(m.value_at(100, 0.0), 1.0); + } + + #[test] + fn write_range_preserves_outside() { + let mut l = AutomationLane::new(AutoParam::Volume); + l.set_point(0, 0.0); + l.set_point(1000, 0.0); + l.write_range(200, 400, -20.0, 0.0); + assert_eq!(l.value_at(100, 0.0), 0.0); + assert_eq!(l.value_at(300, 0.0), -20.0); + assert_eq!(l.value_at(600, 0.0), 0.0); + } + + #[test] + fn thin_removes_collinear() { + let mut l = AutomationLane::new(AutoParam::Volume); + for i in 0..=10 { + l.set_point(i * 10, -(i as f32)); + } + assert_eq!(l.thin(0.01), 9); + assert_eq!(l.points.len(), 2); + } + + #[test] + fn param_ids() { + assert_eq!(AutoParam::parse("send_b_level"), Some(AutoParam::SendLevel(1))); + assert_eq!(AutoParam::parse("plugin:a:threshold"), Some(AutoParam::Plugin { slot: 0, param: "threshold".into() })); + assert_eq!(AutoParam::parse("nope"), None); + assert_eq!(AutomationMode::from_id("touch/latch"), Some(AutomationMode::TouchLatch)); + } + + #[test] + fn set_point_clamps_nan() { + let mut l = AutomationLane::new(AutoParam::Pan(0)); + l.set_point(0, f32::NAN); + l.set_point(1, 5.0); + assert_eq!(l.points[0].value, 0.0); + assert_eq!(l.points[1].value, 1.0); + } +} diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs new file mode 100644 index 00000000..d915530a --- /dev/null +++ b/crates/model/src/clip.rs @@ -0,0 +1,265 @@ +//! Clips: windows onto audio sources or MIDI sequences, placed on a playlist. + +use crate::{ClipId, SourceId}; +use soundcraft_midi::Sequence; +use soundcraft_time::{Range, Samples}; + +/// Fade curve shapes (Pro Tools offers standard, equal-power, S-curve and custom presets). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum FadeShape { + Linear, + #[default] + EqualPower, + SCurve, + Exponential, + Logarithmic, +} + +impl FadeShape { + pub const ALL: [FadeShape; 5] = [FadeShape::Linear, FadeShape::EqualPower, FadeShape::SCurve, FadeShape::Exponential, FadeShape::Logarithmic]; + + /// Fade-in gain at `t` ∈ [0,1] (fade-outs use `gain(1 - t)`). + pub fn gain(self, t: f32) -> f32 { + let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) }; + match self { + FadeShape::Linear => t, + FadeShape::EqualPower => (t * std::f32::consts::FRAC_PI_2).sin(), + FadeShape::SCurve => 0.5 - 0.5 * (t * std::f32::consts::PI).cos(), + FadeShape::Exponential => t * t * t, + FadeShape::Logarithmic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), + } + } + + pub fn label(self) -> &'static str { + match self { + FadeShape::Linear => "Linear", + FadeShape::EqualPower => "Equal Power", + FadeShape::SCurve => "S-Curve", + FadeShape::Exponential => "Exponential", + FadeShape::Logarithmic => "Logarithmic", + } + } + + pub fn from_id(s: &str) -> Option { + let s = s.to_ascii_lowercase().replace(['-', '_', ' '], ""); + FadeShape::ALL.into_iter().find(|f| f.label().to_ascii_lowercase().replace(['-', ' '], "") == s) + } +} + +/// A fade at a clip boundary, `len` samples long. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub struct Fade { + pub len: Samples, + pub shape: FadeShape, +} + +/// What a clip plays. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum ClipContent { + /// Audio from `source`, starting `offset` samples into the file. + Audio { source: SourceId, offset: Samples }, + /// MIDI notes, with ticks relative to the clip start. + Midi { sequence: Sequence }, +} + +/// A clip on a playlist. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Clip { + pub id: ClipId, + pub name: String, + /// Timeline start (samples). + pub start: Samples, + /// Length (samples, > 0). + pub length: Samples, + pub content: ClipContent, + /// Static clip gain in dB. + #[serde(default)] + pub gain_db: f32, + /// Clip gain envelope breakpoints: (offset from clip start, dB). Empty = flat `gain_db`. + #[serde(default)] + pub gain_env: Vec<(Samples, f32)>, + #[serde(default)] + pub fade_in: Fade, + #[serde(default)] + pub fade_out: Fade, + #[serde(default)] + pub muted: bool, + #[serde(default)] + pub edit_locked: bool, + #[serde(default)] + pub time_locked: bool, + #[serde(default)] + pub rating: u8, + /// Sync point offset from clip start. + #[serde(default)] + pub sync_point: Samples, + /// Optional colour override (RGB). + #[serde(default)] + pub color: Option<[u8; 3]>, + /// Elastic / Vari-speed ratio (1.0 = none) — applied by rendered processing. + #[serde(default = "one")] + pub stretch: f64, + /// Reversed playback (AudioSuite Reverse renders into a new source; this is for previews). + #[serde(default)] + pub group: Option, +} + +fn one() -> f64 { + 1.0 +} + +impl Clip { + pub fn audio(id: ClipId, name: impl Into, source: SourceId, offset: Samples, start: Samples, length: Samples) -> Clip { + Clip { + id, + name: name.into(), + start, + length: length.max(1), + content: ClipContent::Audio { source, offset }, + gain_db: 0.0, + gain_env: Vec::new(), + fade_in: Fade::default(), + fade_out: Fade::default(), + muted: false, + edit_locked: false, + time_locked: false, + rating: 0, + sync_point: 0, + color: None, + stretch: 1.0, + group: None, + } + } + + pub fn midi(id: ClipId, name: impl Into, start: Samples, length: Samples, sequence: Sequence) -> Clip { + let mut c = Clip::audio(id, name, SourceId(0), 0, start, length); + c.content = ClipContent::Midi { sequence }; + c + } + + pub fn end(&self) -> Samples { + self.start.saturating_add(self.length) + } + + pub fn range(&self) -> Range { + Range { start: self.start, end: self.end() } + } + + pub fn is_audio(&self) -> bool { + matches!(self.content, ClipContent::Audio { .. }) + } + + pub fn source(&self) -> Option { + match self.content { + ClipContent::Audio { source, .. } => Some(source), + ClipContent::Midi { .. } => None, + } + } + + pub fn source_offset(&self) -> Samples { + match self.content { + ClipContent::Audio { offset, .. } => offset, + ClipContent::Midi { .. } => 0, + } + } + + /// Gain (linear) at `rel` samples from the clip start: clip gain × envelope × fades. + pub fn gain_at(&self, rel: Samples) -> f32 { + let mut db = self.gain_db; + if !self.gain_env.is_empty() { + db += env_value(&self.gain_env, rel); + } + let mut g = db_to_gain(db); + if self.fade_in.len > 0 && rel < self.fade_in.len { + g *= self.fade_in.shape.gain(rel as f32 / self.fade_in.len as f32); + } + let from_end = self.length - rel; + if self.fade_out.len > 0 && from_end <= self.fade_out.len { + g *= self.fade_out.shape.gain(from_end as f32 / self.fade_out.len as f32); + } + g + } + + /// Move the start edge to `new_start` keeping material anchored (trim start). + pub fn trim_start_to(&mut self, new_start: Samples) { + let new_start = new_start.min(self.end() - 1); + let delta = new_start - self.start; + if let ClipContent::Audio { offset, .. } = &mut self.content { + *offset = offset.saturating_add(delta); + } + // MIDI note ticks are re-anchored by the engine, which knows the tempo map. + self.length -= delta; + self.start = new_start; + self.fade_in.len = self.fade_in.len.min(self.length); + self.gain_env = self.gain_env.iter().filter_map(|&(t, v)| (t - delta >= 0).then_some((t - delta, v))).collect(); + } + + /// Move the end edge to `new_end`. + pub fn trim_end_to(&mut self, new_end: Samples) { + let new_end = new_end.max(self.start + 1); + self.length = new_end - self.start; + self.fade_out.len = self.fade_out.len.min(self.length); + } +} + +fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 { + let mut prev: Option<(Samples, f32)> = None; + for &(t, v) in env { + if t >= at { + return match prev { + Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32), + _ => v, + }; + } + prev = Some((t, v)); + } + prev.map_or(0.0, |(_, v)| v) +} + +pub(crate) fn db_to_gain(db: f32) -> f32 { + if db.is_nan() || db <= -144.0 { 0.0 } else { 10f32.powf(db.min(48.0) / 20.0) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fade_shapes_hit_endpoints() { + for s in FadeShape::ALL { + assert!(s.gain(0.0).abs() < 1e-6, "{s:?}"); + assert!((s.gain(1.0) - 1.0).abs() < 1e-6, "{s:?}"); + assert_eq!(s.gain(f32::NAN), 0.0); + assert_eq!(FadeShape::from_id(s.label()), Some(s)); + } + } + + #[test] + fn gain_with_fades() { + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 100, 1000); + c.fade_in = Fade { len: 100, shape: FadeShape::Linear }; + c.fade_out = Fade { len: 100, shape: FadeShape::Linear }; + assert!(c.gain_at(0).abs() < 1e-6); + assert!((c.gain_at(50) - 0.5).abs() < 1e-6); + assert!((c.gain_at(500) - 1.0).abs() < 1e-6); + assert!((c.gain_at(950) - 0.5).abs() < 1e-6); + } + + #[test] + fn gain_envelope_interpolates() { + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 0, 1000); + c.gain_env = vec![(0, 0.0), (1000, -20.0)]; + assert!((c.gain_at(500) - db_to_gain(-10.0)).abs() < 1e-4); + } + + #[test] + fn trims_keep_material_anchored() { + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, 100, 1000); + c.trim_start_to(300); + assert_eq!((c.start, c.length, c.source_offset()), (300, 800, 1200)); + c.trim_end_to(500); + assert_eq!(c.length, 200); + c.trim_end_to(-50); + assert_eq!(c.length, 1); + } +} diff --git a/crates/model/src/ids.rs b/crates/model/src/ids.rs new file mode 100644 index 00000000..441ced1a --- /dev/null +++ b/crates/model/src/ids.rs @@ -0,0 +1,21 @@ +//! Typed ids. All ids come from one session-wide counter so they never collide across kinds. + +macro_rules! id_type { + ($name:ident) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, serde::Serialize, serde::Deserialize)] + #[serde(transparent)] + pub struct $name(pub u64); + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} + +id_type!(TrackId); +id_type!(ClipId); +id_type!(SourceId); +id_type!(BusId); +id_type!(GroupId); +id_type!(MarkerId); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs new file mode 100644 index 00000000..1a126f03 --- /dev/null +++ b/crates/model/src/lib.rs @@ -0,0 +1,26 @@ +//! The SoundCraft session document. +//! +//! A [`Session`] is pure data: tracks with playlists of clips, the mixer (inserts, sends, routing, +//! automation), tempo/meter, memory locations and groups. Audio sample data lives in +//! [`SourcePool`] behind `Arc`s, so cloning a session for undo is cheap. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod automation; +mod clip; +mod ids; +mod markers; +mod mixer; +mod session; +mod source; +mod track; + +pub use automation::{AutoParam, AutomationLane, AutomationMode, AutomationPoint}; +pub use clip::{Clip, ClipContent, Fade, FadeShape}; +pub use ids::{BusId, ClipId, GroupId, MarkerId, SourceId, TrackId}; +pub use markers::{Group, MarkerKind, MemoryLocation}; +pub use mixer::{Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, fader_db_to_pos, fader_pos_to_db}; +pub use session::{BitDepthSetting, EditMode, EditState, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState}; +pub use source::{Source, SourceAudio, SourcePool}; +pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind}; + +pub use soundcraft_time as time; diff --git a/crates/model/src/markers.rs b/crates/model/src/markers.rs new file mode 100644 index 00000000..4441ebca --- /dev/null +++ b/crates/model/src/markers.rs @@ -0,0 +1,58 @@ +//! Memory locations (markers and stored selections) and mix/edit groups. + +use crate::{GroupId, MarkerId, TrackId}; +use soundcraft_time::Samples; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum MarkerKind { + /// A point marker on a ruler. + #[default] + Marker, + /// A stored selection range. + Selection, + /// A location that only recalls view settings. + None, +} + +/// A memory location (Window › Memory Locations). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MemoryLocation { + pub id: MarkerId, + /// User-visible number (1-based, unique). + pub number: u32, + pub name: String, + pub kind: MarkerKind, + pub start: Samples, + pub end: Samples, + #[serde(default)] + pub comments: String, + #[serde(default)] + pub color: Option<[u8; 3]>, + /// Marker ruler lane 1..5. + #[serde(default = "one")] + pub ruler: u8, + /// Track-lane marker on this track, if any. + #[serde(default)] + pub track: Option, +} + +fn one() -> u8 { + 1 +} + +/// A track group (Track › Group…). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Group { + pub id: GroupId, + pub name: String, + /// Group letter id shown in the Groups list ("a", "b", …). + pub letter: char, + pub members: Vec, + pub edit: bool, + pub mix: bool, + pub active: bool, + pub color: [u8; 3], + /// Shared attributes linked by the group (volume, mute, solo, rec, etc.). + #[serde(default)] + pub attributes: Vec, +} diff --git a/crates/model/src/mixer.rs b/crates/model/src/mixer.rs new file mode 100644 index 00000000..b853d5c0 --- /dev/null +++ b/crates/model/src/mixer.rs @@ -0,0 +1,279 @@ +//! Per-track mixer state: inserts, sends, routing, fader, pan, mute/solo. + +use crate::{AutomationMode, BusId}; +use std::collections::BTreeMap; + +pub const INSERT_SLOTS: usize = 10; +pub const SEND_SLOTS: usize = 10; + +/// Channel formats (track widths). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum ChannelFormat { + #[default] + Mono, + Stereo, + Lcr, + Quad, + Lcrs, + Surround50, + Surround51, + Surround70, + Surround71, + Atmos712, + Atmos714, +} + +impl ChannelFormat { + pub const ALL: [ChannelFormat; 11] = [ + ChannelFormat::Mono, + ChannelFormat::Stereo, + ChannelFormat::Lcr, + ChannelFormat::Quad, + ChannelFormat::Lcrs, + ChannelFormat::Surround50, + ChannelFormat::Surround51, + ChannelFormat::Surround70, + ChannelFormat::Surround71, + ChannelFormat::Atmos712, + ChannelFormat::Atmos714, + ]; + pub fn channels(self) -> usize { + match self { + ChannelFormat::Mono => 1, + ChannelFormat::Stereo => 2, + ChannelFormat::Lcr => 3, + ChannelFormat::Quad | ChannelFormat::Lcrs => 4, + ChannelFormat::Surround50 => 5, + ChannelFormat::Surround51 => 6, + ChannelFormat::Surround70 => 7, + ChannelFormat::Surround71 => 8, + ChannelFormat::Atmos712 => 10, + ChannelFormat::Atmos714 => 12, + } + } + pub fn label(self) -> &'static str { + match self { + ChannelFormat::Mono => "Mono", + ChannelFormat::Stereo => "Stereo", + ChannelFormat::Lcr => "LCR", + ChannelFormat::Quad => "Quad", + ChannelFormat::Lcrs => "LCRS", + ChannelFormat::Surround50 => "5.0", + ChannelFormat::Surround51 => "5.1", + ChannelFormat::Surround70 => "7.0", + ChannelFormat::Surround71 => "7.1", + ChannelFormat::Atmos712 => "7.1.2", + ChannelFormat::Atmos714 => "7.1.4", + } + } + pub fn from_id(s: &str) -> Option { + ChannelFormat::ALL.into_iter().find(|f| f.label().eq_ignore_ascii_case(s)) + } + /// Best format for a channel count. + pub fn for_channels(n: usize) -> ChannelFormat { + ChannelFormat::ALL.into_iter().find(|f| f.channels() == n).unwrap_or(if n <= 1 { ChannelFormat::Mono } else { ChannelFormat::Stereo }) + } +} + +/// An internal mix bus (Pro Tools "bus" paths). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Bus { + pub id: BusId, + pub name: String, + pub format: ChannelFormat, +} + +/// Physical output paths (from the I/O setup). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct OutputPath { + pub name: String, + /// First hardware channel (0-based). + pub first_channel: u16, + pub format: ChannelFormat, +} + +/// Where a signal goes or comes from. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum Route { + #[default] + None, + /// The main output (hardware outputs 1-2, or the bounce source). + Main, + Bus(BusId), + /// Hardware input/output by path name. + Hardware(String), +} + +/// A plugin instance on an insert slot. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Insert { + /// Registry id from `soundcraft-dsp` (e.g. `eq7`, `comp`). + pub plugin: String, + #[serde(default)] + pub params: BTreeMap, + #[serde(default)] + pub bypass: bool, + #[serde(default = "yes")] + pub active: bool, + /// Preset name shown in the plugin window header. + #[serde(default)] + pub preset: String, +} + +fn yes() -> bool { + true +} + +impl Insert { + pub fn new(plugin: impl Into) -> Self { + Insert { plugin: plugin.into(), params: BTreeMap::new(), bypass: false, active: true, preset: String::from("") } + } +} + +/// A send to a bus. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SendSlot { + pub target: Route, + pub level_db: f32, + pub pan: f32, + pub mute: bool, + pub pre_fader: bool, + #[serde(default = "yes")] + pub follow_main_pan: bool, +} + +impl SendSlot { + pub fn new(target: Route) -> Self { + SendSlot { target, level_db: -144.0, pan: 0.0, mute: false, pre_fader: false, follow_main_pan: true } + } +} + +/// The channel strip. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Mixer { + pub volume_db: f32, + /// One panner per source channel for mono/stereo (stereo tracks: two panners, default hard L/R). + pub pan: Vec, + pub mute: bool, + pub solo: bool, + #[serde(default)] + pub solo_safe: bool, + #[serde(default)] + pub record_arm: bool, + #[serde(default)] + pub input_monitor: bool, + #[serde(default)] + pub phase_invert: bool, + pub input: Route, + pub output: Route, + pub inserts: Vec>, + pub sends: Vec>, + pub automation_mode: AutomationMode, + /// Input gain / trim, dB. + #[serde(default)] + pub trim_db: f32, + /// VCA master that controls this track, if any (track id of the VCA). + #[serde(default)] + pub vca: Option, +} + +impl Mixer { + pub fn new(format: ChannelFormat) -> Self { + let pan = match format { + ChannelFormat::Mono => vec![0.0], + ChannelFormat::Stereo => vec![-1.0, 1.0], + _ => Vec::new(), + }; + Mixer { + volume_db: 0.0, + pan, + mute: false, + solo: false, + solo_safe: false, + record_arm: false, + input_monitor: false, + phase_invert: false, + input: Route::None, + output: Route::Main, + inserts: vec![None; INSERT_SLOTS], + sends: vec![None; SEND_SLOTS], + automation_mode: AutomationMode::Read, + trim_db: 0.0, + vca: None, + } + } + + /// Clamp everything into range (after deserialising untrusted files). + pub fn sanitize(&mut self) { + self.volume_db = finite_or(self.volume_db, 0.0).clamp(-144.0, 12.0); + for p in &mut self.pan { + *p = finite_or(*p, 0.0).clamp(-1.0, 1.0); + } + self.inserts.resize(INSERT_SLOTS, None); + self.sends.resize(SEND_SLOTS, None); + for s in self.sends.iter_mut().flatten() { + s.level_db = finite_or(s.level_db, -144.0).clamp(-144.0, 12.0); + s.pan = finite_or(s.pan, 0.0).clamp(-1.0, 1.0); + } + self.trim_db = finite_or(self.trim_db, 0.0).clamp(-144.0, 24.0); + } +} + +pub(crate) fn finite_or(v: f32, d: f32) -> f32 { + if v.is_finite() { v } else { d } +} + +/// Fader taper used by Pro Tools-style faders: position 0..1 ↔ dB (-inf..+12). +pub fn fader_pos_to_db(pos: f32) -> f32 { + let p = finite_or(pos, 0.0).clamp(0.0, 1.0); + if p <= 0.0 { + return -144.0; + } + // Piecewise: top 75 % covers -24..+12 linearly-ish, the bottom compresses to -inf. + if p >= 0.25 { -24.0 + (p - 0.25) / 0.75 * 36.0 } else { -24.0 - (1.0 - p / 0.25).powf(1.5) * 120.0 } +} + +pub fn fader_db_to_pos(db: f32) -> f32 { + let db = finite_or(db, -144.0); + if db <= -144.0 { + return 0.0; + } + if db >= -24.0 { + (0.25 + (db.min(12.0) + 24.0) / 36.0 * 0.75).min(1.0) + } else { + let x = ((-24.0 - db) / 120.0).clamp(0.0, 1.0).powf(1.0 / 1.5); + ((1.0 - x) * 0.25).max(0.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fader_taper_round_trips() { + for db in [-100.0f32, -60.0, -24.0, -10.0, 0.0, 6.0, 12.0] { + let p = fader_db_to_pos(db); + assert!((fader_pos_to_db(p) - db).abs() < 0.05, "{db} → {p}"); + } + assert_eq!(fader_pos_to_db(0.0), -144.0); + assert_eq!(fader_db_to_pos(f32::NAN), 0.0); + } + + #[test] + fn mixer_defaults_and_sanitize() { + let mut m = Mixer::new(ChannelFormat::Stereo); + assert_eq!(m.pan, vec![-1.0, 1.0]); + m.volume_db = f32::INFINITY; + m.inserts.truncate(2); + m.sanitize(); + assert_eq!(m.volume_db, 0.0); + assert_eq!(m.inserts.len(), INSERT_SLOTS); + } + + #[test] + fn formats() { + assert_eq!(ChannelFormat::for_channels(6), ChannelFormat::Surround51); + assert_eq!(ChannelFormat::from_id("stereo"), Some(ChannelFormat::Stereo)); + } +} diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs new file mode 100644 index 00000000..cb22e242 --- /dev/null +++ b/crates/model/src/session.rs @@ -0,0 +1,563 @@ +//! The session: the whole document. + +use crate::{ + Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool, + TRACK_COLORS, Track, TrackId, TrackKind, +}; +use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat}; + +/// Native session file extension. +pub const SESSION_EXTENSION: &str = "scraft"; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SessionError { + #[error("not a SoundCraft session: {0}")] + Format(String), + #[error("session file version {0} is newer than this SoundCraft supports")] + Version(u32), + #[error("{0}")] + Invalid(String), +} + +/// Session bit depth for recording/rendering. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum BitDepthSetting { + Int16, + #[default] + Int24, + Float32, +} + +/// Edit modes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum EditMode { + Shuffle, + #[default] + Slip, + Spot, + Grid, + /// Relative grid: moves keep their offset from the grid. + GridRelative, +} + +impl EditMode { + pub fn label(self) -> &'static str { + match self { + EditMode::Shuffle => "SHUFFLE", + EditMode::Slip => "SLIP", + EditMode::Spot => "SPOT", + EditMode::Grid => "GRID", + EditMode::GridRelative => "REL GRID", + } + } + pub fn from_id(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "shuffle" => Some(EditMode::Shuffle), + "slip" => Some(EditMode::Slip), + "spot" => Some(EditMode::Spot), + "grid" | "absolute_grid" => Some(EditMode::Grid), + "relative_grid" | "rel_grid" | "grid_relative" => Some(EditMode::GridRelative), + _ => None, + } + } + pub fn is_grid(self) -> bool { + matches!(self, EditMode::Grid | EditMode::GridRelative) + } +} + +/// Edit tools. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum Tool { + Zoom, + Trim, + #[default] + Selector, + Grabber, + Scrubber, + Pencil, + Smart, +} + +impl Tool { + pub const ALL: [Tool; 7] = [Tool::Zoom, Tool::Trim, Tool::Selector, Tool::Grabber, Tool::Scrubber, Tool::Pencil, Tool::Smart]; + pub fn id(self) -> &'static str { + match self { + Tool::Zoom => "zoom", + Tool::Trim => "trim", + Tool::Selector => "selector", + Tool::Grabber => "grabber", + Tool::Scrubber => "scrubber", + Tool::Pencil => "pencil", + Tool::Smart => "smart", + } + } + pub fn label(self) -> &'static str { + match self { + Tool::Zoom => "Zoomer", + Tool::Trim => "Trim", + Tool::Selector => "Selector", + Tool::Grabber => "Grabber", + Tool::Scrubber => "Scrubber", + Tool::Pencil => "Pencil", + Tool::Smart => "Smart Tool", + } + } + pub fn from_id(s: &str) -> Option { + Tool::ALL.into_iter().find(|t| t.id() == s.to_ascii_lowercase()) + } + /// F-key shortcut (F5..F10) like the incumbent. + pub fn fkey(self) -> Option<&'static str> { + match self { + Tool::Zoom => Some("F5"), + Tool::Trim => Some("F6"), + Tool::Selector => Some("F7"), + Tool::Grabber => Some("F8"), + Tool::Scrubber => Some("F9"), + Tool::Pencil => Some("F10"), + Tool::Smart => None, + } + } +} + +/// Track view lanes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum TrackView { + Waveform, + Blocks, + Volume, + Pan, + Mute, +} + +/// Horizontal zoom + scroll. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ZoomState { + /// Samples per pixel (point). + pub samples_per_px: f64, + /// Left edge of the visible timeline. + pub scroll: Samples, + /// Waveform vertical zoom factor. + pub waveform_zoom: f32, + /// Zoom preset memories 1..5. + pub presets: [f64; 5], +} + +impl Default for ZoomState { + fn default() -> Self { + ZoomState { samples_per_px: 1024.0, scroll: 0, waveform_zoom: 1.0, presets: [64.0, 256.0, 1024.0, 4096.0, 16384.0] } + } +} + +/// Edit-window state saved with the session (selection, tools, modes, view settings). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct EditState { + pub tool: Tool, + pub edit_mode: EditMode, + /// The edit selection (a point = insertion position). + pub selection: Range, + /// Tracks in the edit selection. + pub selected_tracks: Vec, + /// Clips explicitly selected (Grabber selection). + #[serde(default)] + pub selected_clips: Vec, + /// Playhead position when stopped. + pub playhead: Samples, + pub grid: GridValue, + pub nudge: GridValue, + #[serde(default = "yes")] + pub grid_lines: bool, + pub main_counter: TimeFormat, + pub sub_counter: Option, + pub zoom: ZoomState, + /// Visible rulers. + pub rulers: Vec, + pub link_timeline_edit: bool, + pub link_track_edit: bool, + pub insertion_follows_playback: bool, + pub loop_playback: bool, + pub pre_post_roll: bool, + pub pre_roll: Samples, + pub post_roll: Samples, + pub tab_to_transient: bool, + pub automation_follows_edit: bool, + pub markers_follow_edit: bool, + pub layered_editing: bool, + pub mirrored_midi: bool, + pub click: bool, + pub countoff: bool, + pub countoff_bars: u32, + pub midi_merge: bool, + pub record_mode: String, + pub loop_record: bool, + pub quickpunch: bool, + pub scrolling: String, + pub solo_mode: String, + pub pre_fader_metering: bool, + pub delay_compensation: bool, + pub keyboard_focus: String, + /// Timeline selection when it is unlinked from the edit selection. + pub timeline_selection: Range, +} + +fn yes() -> bool { + true +} + +impl Default for EditState { + fn default() -> Self { + EditState { + tool: Tool::Smart, + edit_mode: EditMode::Slip, + selection: Range::default(), + selected_tracks: Vec::new(), + selected_clips: Vec::new(), + playhead: 0, + grid: GridValue::default(), + nudge: GridValue::Seconds(1.0), + grid_lines: true, + main_counter: TimeFormat::MinSecs, + sub_counter: Some(TimeFormat::Samples), + zoom: ZoomState::default(), + rulers: vec!["bars_beats".into(), "min_secs".into(), "tempo".into(), "meter".into(), "markers".into()], + link_timeline_edit: true, + link_track_edit: false, + insertion_follows_playback: false, + loop_playback: false, + pre_post_roll: false, + pre_roll: 96_000, + post_roll: 96_000, + tab_to_transient: false, + automation_follows_edit: true, + markers_follow_edit: false, + layered_editing: false, + mirrored_midi: false, + click: false, + countoff: false, + countoff_bars: 2, + midi_merge: false, + record_mode: "normal".into(), + loop_record: false, + quickpunch: false, + scrolling: "page".into(), + solo_mode: "sip".into(), + pre_fader_metering: false, + delay_compensation: true, + keyboard_focus: "commands".into(), + timeline_selection: Range::default(), + } + } +} + +/// The document. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Session { + pub format_version: u32, + pub name: String, + pub sample_rate: SampleRate, + pub bit_depth: BitDepthSetting, + pub frame_rate: FrameRate, + /// Timecode of sample 0 (session start), as a sample count. + pub timecode_start: Samples, + pub tempo: TempoMap, + #[serde(default)] + pub key_signatures: Vec<(Samples, String)>, + pub tracks: Vec, + pub sources: Vec, + pub busses: Vec, + pub outputs: Vec, + pub markers: Vec, + pub groups: Vec, + pub edit: EditState, + pub next_id: u64, + #[serde(default)] + pub comments: String, + #[serde(skip)] + pub pool: SourcePool, +} + +impl Default for Session { + fn default() -> Self { + Session::new("Untitled", SampleRate::HZ_48000) + } +} + +impl Session { + pub const FORMAT_VERSION: u32 = 1; + + pub fn new(name: impl Into, sample_rate: SampleRate) -> Self { + let mut s = Session { + format_version: Self::FORMAT_VERSION, + name: name.into(), + sample_rate, + bit_depth: BitDepthSetting::Int24, + frame_rate: FrameRate::Fps30, + timecode_start: 0, + tempo: TempoMap::default(), + key_signatures: Vec::new(), + tracks: Vec::new(), + sources: Vec::new(), + busses: Vec::new(), + outputs: vec![OutputPath { name: "Out 1-2".into(), first_channel: 0, format: ChannelFormat::Stereo }], + markers: Vec::new(), + groups: Vec::new(), + edit: EditState::default(), + next_id: 1, + comments: String::new(), + pool: SourcePool::default(), + }; + s.edit.pre_roll = sample_rate.samples(2.0); + s.edit.post_roll = sample_rate.samples(2.0); + s + } + + /// Allocate a fresh id. + pub fn alloc(&mut self) -> u64 { + let id = self.next_id.max(1); + self.next_id = id.saturating_add(1); + id + } + + // ---- lookups ------------------------------------------------------------------------- + + pub fn track(&self, id: TrackId) -> Option<&Track> { + self.tracks.iter().find(|t| t.id == id) + } + pub fn track_mut(&mut self, id: TrackId) -> Option<&mut Track> { + self.tracks.iter_mut().find(|t| t.id == id) + } + pub fn track_index(&self, id: TrackId) -> Option { + self.tracks.iter().position(|t| t.id == id) + } + pub fn track_by_name(&self, name: &str) -> Option<&Track> { + self.tracks.iter().find(|t| t.name == name).or_else(|| self.tracks.iter().find(|t| t.name.eq_ignore_ascii_case(name))) + } + pub fn master(&self) -> Option<&Track> { + self.tracks.iter().find(|t| t.kind == TrackKind::Master) + } + pub fn source(&self, id: SourceId) -> Option<&Source> { + self.sources.iter().find(|s| s.id == id) + } + pub fn bus(&self, id: BusId) -> Option<&Bus> { + self.busses.iter().find(|b| b.id == id) + } + pub fn bus_by_name(&self, name: &str) -> Option<&Bus> { + self.busses.iter().find(|b| b.name.eq_ignore_ascii_case(name)) + } + pub fn group(&self, id: GroupId) -> Option<&Group> { + self.groups.iter().find(|g| g.id == id) + } + pub fn marker(&self, id: MarkerId) -> Option<&MemoryLocation> { + self.markers.iter().find(|m| m.id == id) + } + + /// Find a clip anywhere (active playlists). + pub fn find_clip(&self, id: ClipId) -> Option<(TrackId, &Clip)> { + self.tracks.iter().find_map(|t| t.clips().iter().find(|c| c.id == id).map(|c| (t.id, c))) + } + + pub fn find_clip_mut(&mut self, id: ClipId) -> Option<&mut Clip> { + self.tracks.iter_mut().find_map(|t| t.playlist_mut().and_then(|p| p.clip_mut(id))) + } + + /// End of the last clip on any track (session length). + pub fn content_end(&self) -> Samples { + self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0) + } + + pub fn next_track_color(&self) -> [u8; 3] { + let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count(); + TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128]) + } + + /// A unique track name: `base`, or `base 1`, `base 2` … like the incumbent ("Audio 1"). + pub fn unique_track_name(&self, base: &str) -> String { + let taken = |n: &str| self.tracks.iter().any(|t| t.name == n); + if !base.is_empty() && !taken(base) && !matches!(base, "Audio" | "Aux" | "MIDI" | "Inst" | "VCA" | "Folder" | "Master") { + return base.to_string(); + } + let mut i = 1; + loop { + let cand = format!("{base} {i}"); + if !taken(&cand) { + return cand; + } + i += 1; + } + } + + /// Add a track with defaults; returns its id. + pub fn add_track(&mut self, kind: TrackKind, format: ChannelFormat, name: Option<&str>) -> TrackId { + let id = TrackId(self.alloc()); + let name = self.unique_track_name(name.unwrap_or(kind.default_name())); + let color = if kind == TrackKind::Master { [140, 140, 140] } else { self.next_track_color() }; + let mut t = Track::new(id, name, kind, format, color); + if kind == TrackKind::Master { + t.mixer.output = crate::Route::Main; + t.mixer.input = crate::Route::Main; + } + // Master faders sit at the bottom like the incumbent; others go before any master. + let pos = if kind == TrackKind::Master { + self.tracks.len() + } else { + self.tracks.iter().position(|t| t.kind == TrackKind::Master).unwrap_or(self.tracks.len()) + }; + self.tracks.insert(pos, t); + id + } + + pub fn add_bus(&mut self, name: &str, format: ChannelFormat) -> BusId { + if let Some(b) = self.bus_by_name(name) { + return b.id; + } + let id = BusId(self.alloc()); + self.busses.push(Bus { id, name: name.to_string(), format }); + id + } + + /// Add a memory location; returns its id. + pub fn add_marker(&mut self, name: &str, kind: MarkerKind, start: Samples, end: Samples) -> MarkerId { + let id = MarkerId(self.alloc()); + let number = self.markers.iter().map(|m| m.number).max().unwrap_or(0).saturating_add(1); + self.markers.push(MemoryLocation { + id, + number, + name: name.to_string(), + kind, + start, + end: end.max(start), + comments: String::new(), + color: None, + ruler: 1, + track: None, + }); + self.markers.sort_by_key(|m| m.start); + id + } + + pub fn new_clip_id(&mut self) -> ClipId { + ClipId(self.alloc()) + } + + // ---- persistence --------------------------------------------------------------------- + + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self).map_err(|e| SessionError::Format(e.to_string())) + } + + /// Parse a session; decoded audio must be attached separately (`pool`). + pub fn from_json(text: &str) -> Result { + let v: serde_json::Value = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?; + let ver = v.get("format_version").and_then(serde_json::Value::as_u64).ok_or_else(|| SessionError::Format("missing format_version".into()))?; + if ver > u64::from(Self::FORMAT_VERSION) { + return Err(SessionError::Version(u32::try_from(ver).unwrap_or(u32::MAX))); + } + let mut s: Session = serde_json::from_value(v).map_err(|e| SessionError::Format(e.to_string()))?; + s.sanitize(); + Ok(s) + } + + /// Repair invariants after loading untrusted data. + pub fn sanitize(&mut self) { + let max_id = self + .tracks + .iter() + .map(|t| t.id.0) + .chain(self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0)))) + .chain(self.sources.iter().map(|s| s.id.0)) + .chain(self.busses.iter().map(|b| b.id.0)) + .chain(self.markers.iter().map(|m| m.id.0)) + .chain(self.groups.iter().map(|g| g.id.0)) + .max() + .unwrap_or(0); + self.next_id = self.next_id.max(max_id.saturating_add(1)); + for t in &mut self.tracks { + t.mixer.sanitize(); + if t.playlists.is_empty() { + t.playlists.push(crate::Playlist::new(t.name.clone())); + } + if t.active_playlist >= t.playlists.len() { + t.active_playlist = 0; + } + for p in &mut t.playlists { + p.clips.retain(|c| c.length > 0); + for c in &mut p.clips { + c.fade_in.len = c.fade_in.len.clamp(0, c.length); + c.fade_out.len = c.fade_out.len.clamp(0, c.length); + if !c.gain_db.is_finite() { + c.gain_db = 0.0; + } + if !c.stretch.is_finite() || c.stretch <= 0.0 { + c.stretch = 1.0; + } + } + p.sort(); + } + for l in &mut t.automation { + l.points.retain(|p| p.value.is_finite()); + l.points.sort_by_key(|p| p.at); + } + } + if !self.edit.zoom.samples_per_px.is_finite() || self.edit.zoom.samples_per_px <= 0.0 { + self.edit.zoom.samples_per_px = 1024.0; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn add_tracks_names_and_order() { + let mut s = Session::default(); + let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None); + let a = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let b = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let v = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Vocal")); + assert_eq!(s.track(a).map(|t| t.name.as_str()), Some("Audio 1")); + assert_eq!(s.track(b).map(|t| t.name.as_str()), Some("Audio 2")); + assert_eq!(s.track(v).map(|t| t.name.as_str()), Some("Vocal")); + assert_eq!(s.tracks.last().map(|t| t.id), Some(m)); + let v2 = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Vocal")); + assert_eq!(s.track(v2).map(|t| t.name.as_str()), Some("Vocal 1")); + } + + #[test] + fn json_round_trip() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None); + let cid = s.new_clip_id(); + if let Some(p) = s.track_mut(t).and_then(Track::playlist_mut) { + p.clips.push(Clip::audio(cid, "c", SourceId(99), 0, 10, 100)); + } + s.add_marker("Verse", MarkerKind::Marker, 48_000, 48_000); + let text = s.to_json().unwrap(); + let back = Session::from_json(&text).unwrap(); + assert_eq!(back, s); + } + + #[test] + fn rejects_garbage_and_future_versions() { + assert!(Session::from_json("not json").is_err()); + assert!(Session::from_json("{}").is_err()); + let mut v = serde_json::to_value(Session::default()).unwrap(); + v["format_version"] = serde_json::json!(999); + assert!(matches!(Session::from_json(&v.to_string()), Err(SessionError::Version(999)))); + } + + #[test] + fn sanitize_repairs() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + if let Some(tr) = s.track_mut(t) { + tr.active_playlist = 7; + tr.mixer.volume_db = f32::NAN; + } + s.next_id = 0; + s.sanitize(); + let tr = s.track(t).unwrap(); + assert_eq!(tr.active_playlist, 0); + assert_eq!(tr.mixer.volume_db, 0.0); + assert!(s.next_id > t.0); + } +} diff --git a/crates/model/src/source.rs b/crates/model/src/source.rs new file mode 100644 index 00000000..9c8ac6c2 --- /dev/null +++ b/crates/model/src/source.rs @@ -0,0 +1,81 @@ +//! Audio sources (files in the session's Audio Files folder) and their decoded data. + +use crate::SourceId; +use soundcraft_audio_io::{AudioBuffer, FileFormat, peaks::Peaks}; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Metadata for an audio file used by the session (serialised). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Source { + pub id: SourceId, + pub name: String, + /// Path relative to the session folder (or absolute for referenced media). + pub path: String, + pub channels: u16, + pub frames: u64, + pub sample_rate: u32, + pub format: FileFormat, + /// Original BWF time stamp (samples), if any. + #[serde(default)] + pub time_reference: u64, + /// True when the file has not been written to disk yet (recorded/rendered this session). + #[serde(default)] + pub unsaved: bool, +} + +/// Decoded audio plus waveform overviews (not serialised). +#[derive(Debug)] +pub struct SourceAudio { + pub buffer: AudioBuffer, + pub peaks: Vec, +} + +impl SourceAudio { + pub fn new(buffer: AudioBuffer) -> Self { + let peaks = buffer.channels.iter().map(|c| Peaks::build(c)).collect(); + SourceAudio { buffer, peaks } + } + pub fn frames(&self) -> usize { + self.buffer.frames() + } + pub fn channel(&self, ch: usize) -> Option<&[f32]> { + self.buffer.channels.get(ch).map(Vec::as_slice) + } +} + +/// Shared decoded audio, keyed by source id. +#[derive(Debug, Clone, Default)] +pub struct SourcePool { + map: BTreeMap>, +} + +impl SourcePool { + pub fn insert(&mut self, id: SourceId, audio: Arc) { + self.map.insert(id, audio); + } + pub fn get(&self, id: SourceId) -> Option<&Arc> { + self.map.get(&id) + } + pub fn remove(&mut self, id: SourceId) -> Option> { + self.map.remove(&id) + } + pub fn contains(&self, id: SourceId) -> bool { + self.map.contains_key(&id) + } + pub fn len(&self) -> usize { + self.map.len() + } + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + pub fn ids(&self) -> impl Iterator + '_ { + self.map.keys().copied() + } +} + +impl PartialEq for SourcePool { + fn eq(&self, other: &Self) -> bool { + self.map.len() == other.map.len() && self.map.iter().zip(other.map.iter()).all(|((a, x), (b, y))| a == b && Arc::ptr_eq(x, y)) + } +} diff --git a/crates/model/src/track.rs b/crates/model/src/track.rs new file mode 100644 index 00000000..085d9083 --- /dev/null +++ b/crates/model/src/track.rs @@ -0,0 +1,327 @@ +//! Tracks and playlists. + +use crate::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipId, Mixer, TrackId}; +use soundcraft_time::{Range, Samples}; + +/// Track types (Track › New). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum TrackKind { + #[default] + Audio, + Aux, + Master, + Midi, + Instrument, + Vca, + Folder, +} + +impl TrackKind { + pub const ALL: [TrackKind; 7] = + [TrackKind::Audio, TrackKind::Aux, TrackKind::Master, TrackKind::Midi, TrackKind::Instrument, TrackKind::Vca, TrackKind::Folder]; + pub fn label(self) -> &'static str { + match self { + TrackKind::Audio => "Audio Track", + TrackKind::Aux => "Aux Input", + TrackKind::Master => "Master Fader", + TrackKind::Midi => "MIDI Track", + TrackKind::Instrument => "Instrument Track", + TrackKind::Vca => "VCA Master", + TrackKind::Folder => "Routing Folder Track", + } + } + pub fn default_name(self) -> &'static str { + match self { + TrackKind::Audio => "Audio", + TrackKind::Aux => "Aux", + TrackKind::Master => "Master", + TrackKind::Midi => "MIDI", + TrackKind::Instrument => "Inst", + TrackKind::Vca => "VCA", + TrackKind::Folder => "Folder", + } + } + pub fn id(self) -> &'static str { + match self { + TrackKind::Audio => "audio", + TrackKind::Aux => "aux", + TrackKind::Master => "master", + TrackKind::Midi => "midi", + TrackKind::Instrument => "instrument", + TrackKind::Vca => "vca", + TrackKind::Folder => "folder", + } + } + pub fn from_id(s: &str) -> Option { + let s = s.to_ascii_lowercase(); + TrackKind::ALL.into_iter().find(|k| k.id() == s || k.label().eq_ignore_ascii_case(&s)) + } + /// Track kinds that hold clips on a timeline. + pub fn has_playlist(self) -> bool { + matches!(self, TrackKind::Audio | TrackKind::Midi | TrackKind::Instrument) + } + pub fn is_midi(self) -> bool { + matches!(self, TrackKind::Midi | TrackKind::Instrument) + } + /// Kinds that pass audio through the mixer. + pub fn is_audio_path(self) -> bool { + matches!(self, TrackKind::Audio | TrackKind::Aux | TrackKind::Master | TrackKind::Instrument | TrackKind::Folder) + } +} + +/// Track heights (View › Track Height presets). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum TrackHeight { + Micro, + Mini, + Small, + #[default] + Medium, + Large, + Jumbo, + Extreme, + Fit, +} + +impl TrackHeight { + pub const ALL: [TrackHeight; 7] = [ + TrackHeight::Micro, + TrackHeight::Mini, + TrackHeight::Small, + TrackHeight::Medium, + TrackHeight::Large, + TrackHeight::Jumbo, + TrackHeight::Extreme, + ]; + pub fn points(self) -> f32 { + match self { + TrackHeight::Micro => 16.0, + TrackHeight::Mini => 24.0, + TrackHeight::Small => 46.0, + TrackHeight::Medium => 88.0, + TrackHeight::Large => 132.0, + TrackHeight::Jumbo => 220.0, + TrackHeight::Extreme => 440.0, + TrackHeight::Fit => 88.0, + } + } + pub fn label(self) -> &'static str { + match self { + TrackHeight::Micro => "micro", + TrackHeight::Mini => "mini", + TrackHeight::Small => "small", + TrackHeight::Medium => "medium", + TrackHeight::Large => "large", + TrackHeight::Jumbo => "jumbo", + TrackHeight::Extreme => "extreme", + TrackHeight::Fit => "fit to window", + } + } + pub fn from_id(s: &str) -> Option { + TrackHeight::ALL.into_iter().find(|h| h.label().eq_ignore_ascii_case(s)) + } +} + +/// The default track colour palette (original, chosen for SoundCraft). +pub const TRACK_COLORS: [[u8; 3]; 16] = [ + [86, 132, 214], + [94, 170, 214], + [80, 190, 168], + [104, 186, 98], + [168, 196, 76], + [222, 196, 72], + [230, 152, 62], + [222, 104, 72], + [214, 82, 110], + [196, 86, 168], + [150, 98, 206], + [110, 104, 214], + [130, 150, 170], + [178, 140, 110], + [120, 170, 140], + [190, 120, 150], +]; + +/// A playlist: an alternative take of a track's clips. +#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)] +pub struct Playlist { + pub name: String, + /// Clips sorted by start; non-overlapping except at crossfades. + pub clips: Vec, +} + +impl Playlist { + pub fn new(name: impl Into) -> Self { + Playlist { name: name.into(), clips: Vec::new() } + } + pub fn sort(&mut self) { + self.clips.sort_by_key(|c| (c.start, c.id.0)); + } + pub fn clip(&self, id: ClipId) -> Option<&Clip> { + self.clips.iter().find(|c| c.id == id) + } + pub fn clip_mut(&mut self, id: ClipId) -> Option<&mut Clip> { + self.clips.iter_mut().find(|c| c.id == id) + } + pub fn clips_in(&self, r: Range) -> impl Iterator { + self.clips.iter().filter(move |c| c.range().overlaps(&r)) + } + pub fn clip_at(&self, at: Samples) -> Option<&Clip> { + self.clips.iter().rev().find(|c| c.range().contains(at)) + } + pub fn end(&self) -> Samples { + self.clips.iter().map(Clip::end).max().unwrap_or(0) + } +} + +/// A track. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Track { + pub id: TrackId, + pub name: String, + pub kind: TrackKind, + pub format: ChannelFormat, + pub color: [u8; 3], + pub height: TrackHeight, + pub playlists: Vec, + pub active_playlist: usize, + pub mixer: Mixer, + pub automation: Vec, + /// Which lane the track shows: `waveform`/`blocks`/`notes`/`regions` or an automation param id. + pub view: String, + #[serde(default)] + pub hidden: bool, + #[serde(default)] + pub inactive: bool, + #[serde(default)] + pub frozen: bool, + #[serde(default)] + pub comments: String, + /// Folder that contains this track. + #[serde(default)] + pub folder: Option, + #[serde(default = "yes")] + pub folder_open: bool, + /// Instrument plugin (instrument tracks) — registry id. + #[serde(default)] + pub instrument: Option, + /// Ticks (musical) or samples timebase for clips. + #[serde(default)] + pub ticks_timebase: bool, + /// Elastic Audio algorithm, if enabled. + #[serde(default)] + pub elastic: Option, + /// Pinned to top in the Edit window. + #[serde(default)] + pub pinned: bool, +} + +fn yes() -> bool { + true +} + +impl Track { + pub fn new(id: TrackId, name: impl Into, kind: TrackKind, format: ChannelFormat, color: [u8; 3]) -> Self { + let name = name.into(); + let format = if kind.is_midi() && kind == TrackKind::Midi { ChannelFormat::Mono } else { format }; + let view = if kind.is_midi() { + "notes" + } else if kind.has_playlist() { + "waveform" + } else { + "volume" + }; + Track { + id, + playlists: vec![Playlist::new(name.clone())], + name, + kind, + format, + color, + height: TrackHeight::Medium, + active_playlist: 0, + mixer: Mixer::new(format), + automation: Vec::new(), + view: view.into(), + hidden: false, + inactive: false, + frozen: false, + comments: String::new(), + folder: None, + folder_open: true, + instrument: None, + ticks_timebase: kind.is_midi(), + elastic: None, + pinned: false, + } + } + + pub fn channels(&self) -> usize { + self.format.channels() + } + + pub fn playlist(&self) -> Option<&Playlist> { + self.playlists.get(self.active_playlist) + } + + pub fn playlist_mut(&mut self) -> Option<&mut Playlist> { + self.playlists.get_mut(self.active_playlist) + } + + pub fn clips(&self) -> &[Clip] { + self.playlist().map_or(&[], |p| p.clips.as_slice()) + } + + pub fn lane(&self, p: &AutoParam) -> Option<&AutomationLane> { + self.automation.iter().find(|l| &l.param == p) + } + + pub fn lane_mut(&mut self, p: &AutoParam) -> &mut AutomationLane { + let idx = match self.automation.iter().position(|l| &l.param == p) { + Some(i) => i, + None => { + self.automation.push(AutomationLane::new(p.clone())); + self.automation.len() - 1 + } + }; + let len = self.automation.len(); + // `idx` is in range: it came from `position` or is the index of the element just pushed. + &mut self.automation[idx.min(len - 1)] + } + + pub fn is_folder(&self) -> bool { + self.kind == TrackKind::Folder + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_track_defaults() { + let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]); + assert_eq!(t.channels(), 2); + assert_eq!(t.view, "waveform"); + assert_eq!(t.playlists.len(), 1); + let m = Track::new(TrackId(2), "Keys", TrackKind::Midi, ChannelFormat::Stereo, TRACK_COLORS[0]); + assert_eq!(m.format, ChannelFormat::Mono); + assert_eq!(m.view, "notes"); + } + + #[test] + fn lane_mut_creates_once() { + let mut t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Mono, TRACK_COLORS[0]); + t.lane_mut(&AutoParam::Volume).set_point(0, -3.0); + t.lane_mut(&AutoParam::Volume).set_point(10, -6.0); + assert_eq!(t.automation.len(), 1); + assert_eq!(t.lane(&AutoParam::Volume).map(|l| l.points.len()), Some(2)); + } + + #[test] + fn kind_ids() { + for k in TrackKind::ALL { + assert_eq!(TrackKind::from_id(k.id()), Some(k)); + } + } +} diff --git a/crates/playback/Cargo.toml b/crates/playback/Cargo.toml new file mode 100644 index 00000000..e6af720c --- /dev/null +++ b/crates/playback/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "soundcraft-playback" +description = "SoundCraft audio device I/O: realtime playback and recording through the mix engine" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +soundcraft-time.workspace = true +soundcraft-model.workspace = true +soundcraft-mix.workspace = true +soundcraft-dsp.workspace = true +log.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +cpal.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +cpal = { workspace = true, features = ["wasm-bindgen"] } + +[lints] +workspace = true diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs new file mode 100644 index 00000000..6da4a665 --- /dev/null +++ b/crates/playback/src/lib.rs @@ -0,0 +1,404 @@ +//! Realtime playback through the system audio device (cpal: CoreAudio, WASAPI, ALSA/Pulse/ +//! PipeWire via ALSA, JACK, WebAudio). +//! +//! The UI thread owns a [`Player`]; the audio callback owns the [`MixEngine`] and a snapshot of +//! the session (`Arc`) that the UI swaps whenever the document changes. Commands go to +//! the callback through a channel; position and meters come back through atomics and a +//! `try_lock`ed snapshot, so the callback never blocks. When no device is available the player +//! falls back to a silent clock thread so the transport, meters and automation still run. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use soundcraft_mix::{MixEngine, StripMeter}; +use soundcraft_model::{Session, TrackId}; +use soundcraft_time::{Range, Samples}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::{Arc, Mutex, PoisonError}; + +const BLOCK: usize = 512; + +/// Meter values for the UI. +#[derive(Debug, Clone, Default)] +pub struct MeterSnapshot { + pub tracks: HashMap, + pub main: StripMeter, +} + +enum Cmd { + Session(Arc), + Play { from: Samples, end: Option, looped: Option }, + Stop, + Locate(Samples), +} + +struct Shared { + position: AtomicI64, + playing: AtomicBool, + /// Speed × 1000. + speed: AtomicU32, + meters: Mutex, +} + +/// State owned by the audio callback. +struct AudioState { + rx: Receiver, + shared: Arc, + session: Arc, + mix: MixEngine, + pos: Samples, + playing: bool, + end: Option, + looped: Option, + block: Vec>, + /// Rendered frames waiting to be consumed (stereo planar) and the fractional read index. + pending: Vec>, + pending_len: usize, + read: f64, + device_rate: f64, +} + +impl AudioState { + fn new(rx: Receiver, shared: Arc, session: Arc, device_rate: f64) -> Self { + let sr = session.sample_rate.as_f64() as f32; + AudioState { + rx, + shared, + mix: MixEngine::new(sr, BLOCK), + session, + pos: 0, + playing: false, + end: None, + looped: None, + block: vec![vec![0.0; BLOCK]; 2], + pending: vec![vec![0.0; BLOCK]; 2], + pending_len: 0, + read: 0.0, + device_rate, + } + } + + fn drain(&mut self) { + while let Ok(c) = self.rx.try_recv() { + match c { + Cmd::Session(s) => { + if (s.sample_rate.as_f64() - self.session.sample_rate.as_f64()).abs() > 0.5 { + self.mix = MixEngine::new(s.sample_rate.as_f64() as f32, BLOCK); + } + self.session = s; + } + Cmd::Play { from, end, looped } => { + self.pos = from; + self.end = end; + self.looped = looped.filter(|r| r.len() > 64); + self.playing = true; + self.pending_len = 0; + self.read = 0.0; + self.mix.reset(); + } + Cmd::Stop => { + self.playing = false; + self.mix.reset(); + } + Cmd::Locate(at) => { + self.pos = at; + self.pending_len = 0; + self.read = 0.0; + } + } + } + self.shared.playing.store(self.playing, Ordering::Relaxed); + } + + /// Render the next block at the session rate into `pending`. + fn render_next(&mut self) { + let mut n = BLOCK; + if let Some(l) = self.looped + && self.pos >= l.end + { + self.pos = l.start; + } + if let Some(l) = self.looped { + n = n.min(usize::try_from((l.end - self.pos).max(1)).unwrap_or(BLOCK)); + } else if let Some(e) = self.end { + if self.pos >= e { + self.playing = false; + self.shared.playing.store(false, Ordering::Relaxed); + n = 0; + } else { + n = n.min(usize::try_from(e - self.pos).unwrap_or(BLOCK)); + } + } + if n == 0 { + for c in &mut self.pending { + c.iter_mut().for_each(|x| *x = 0.0); + } + self.pending_len = BLOCK; + return; + } + self.mix.render(&self.session, self.pos, n, &mut self.block); + for (d, s) in self.pending.iter_mut().zip(self.block.iter()) { + if let (Some(dd), Some(ss)) = (d.get_mut(..n), s.get(..n)) { + dd.copy_from_slice(ss); + } + } + self.pending_len = n; + self.pos += n as i64; + self.shared.position.store(self.pos, Ordering::Relaxed); + if let Ok(mut m) = self.shared.meters.try_lock() { + m.main = self.mix.main_meter; + for (k, v) in &self.mix.meters { + m.tracks.insert(*k, *v); + } + } + } + + /// Fill an interleaved device buffer. + fn fill(&mut self, out: &mut [f32], channels: usize) { + self.drain(); + let channels = channels.max(1); + if !self.playing { + out.iter_mut().for_each(|x| *x = 0.0); + if let Ok(mut m) = self.shared.meters.try_lock() { + m.main = StripMeter::default(); + m.tracks.clear(); + } + return; + } + let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0; + let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0); + for frame in out.chunks_mut(channels) { + while self.read >= self.pending_len as f64 { + self.read -= self.pending_len as f64; + if !self.playing { + break; + } + self.render_next(); + if self.pending_len == 0 { + break; + } + } + let i0 = self.read.floor() as usize; + let fr = (self.read - i0 as f64) as f32; + for (c, o) in frame.iter_mut().enumerate() { + let ch = self.pending.get(c.min(1)); + let a = ch.and_then(|v| v.get(i0)).copied().unwrap_or(0.0); + let b = ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a); + *o = if c < 2 && self.playing { (a + (b - a) * fr).clamp(-1.0, 1.0) } else { 0.0 }; + } + self.read += step; + } + } +} + +/// The transport and audio device. +pub struct Player { + shared: Arc, + tx: Sender, + #[cfg(not(target_arch = "wasm32"))] + _stream: Option, + #[cfg(target_arch = "wasm32")] + _stream: Option, + pub device_name: String, + pub device_rate: u32, + /// True when no audio device could be opened (silent clock). + pub silent: bool, +} + +impl Player { + /// Open the default output device. Never fails: without a device it runs a silent clock. + pub fn new(session: Arc) -> Player { + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let want = session.sample_rate.hz(); + match open_device(want) { + Ok((device, config, name)) => { + let rate = config.sample_rate.0; + let channels = usize::from(config.channels); + let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate)); + let err_fn = |e: cpal::StreamError| log::warn!("audio stream error: {e}"); + use cpal::traits::{DeviceTrait, StreamTrait}; + match device.build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| state.fill(data, channels), + err_fn, + None, + ) { + Ok(stream) => { + if let Err(e) = stream.play() { + log::warn!("audio stream failed to start: {e}"); + } + log::info!("audio device: {name} @ {rate} Hz, {channels} ch"); + Player { shared, tx, _stream: Some(stream), device_name: name, device_rate: rate, silent: false } + } + Err(e) => { + log::warn!("cannot open audio stream: {e}; using a silent clock"); + let (tx2, rx2) = channel(); + // The state (and its receiver) moved into the failed closure; rebuild. + Player::silent_clock(shared, tx2, rx2, want) + } + } + } + Err(e) => { + log::warn!("no audio output: {e}; using a silent clock"); + let s2 = Arc::clone(&shared); + Player::silent_clock_with(s2, tx, rx, session) + } + } + } + + fn silent_clock(shared: Arc, tx: Sender, rx: Receiver, rate: u32) -> Player { + let s = Arc::new(Session::new("Untitled", soundcraft_time::SampleRate::new(rate).unwrap_or_default())); + Player::silent_clock_with(shared, tx, rx, s) + } + + #[cfg(not(target_arch = "wasm32"))] + fn silent_clock_with(shared: Arc, tx: Sender, rx: Receiver, session: Arc) -> Player { + let rate = session.sample_rate.hz(); + let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate)); + let spawn = std::thread::Builder::new().name("soundcraft-clock".into()).spawn(move || { + let mut buf = vec![0.0f32; 4096]; + let mut last = std::time::Instant::now(); + loop { + std::thread::sleep(std::time::Duration::from_millis(10)); + let now = std::time::Instant::now(); + let frames = ((now - last).as_secs_f64() * state.device_rate) as usize; + last = now; + let n = (frames * 2).min(buf.len()); + if let Some(b) = buf.get_mut(..n) { + state.fill(b, 2); + } + if Arc::strong_count(&state.shared) <= 1 { + break; + } + } + }); + if let Err(e) = spawn { + log::warn!("clock thread failed: {e}"); + } + Player { shared, tx, _stream: None, device_name: "No audio device (silent)".into(), device_rate: rate, silent: true } + } + + #[cfg(target_arch = "wasm32")] + fn silent_clock_with(shared: Arc, tx: Sender, _rx: Receiver, session: Arc) -> Player { + Player { shared, tx, _stream: None, device_name: "No audio device".into(), device_rate: session.sample_rate.hz(), silent: true } + } + + pub fn update_session(&self, s: Arc) { + let _ = self.tx.send(Cmd::Session(s)); + } + + /// Start playback at `from`, stopping at `end`, or looping `looped`. + pub fn play(&self, from: Samples, end: Option, looped: Option) { + self.shared.position.store(from, Ordering::Relaxed); + self.shared.playing.store(true, Ordering::Relaxed); + let _ = self.tx.send(Cmd::Play { from, end, looped }); + } + + pub fn stop(&self) { + self.shared.playing.store(false, Ordering::Relaxed); + let _ = self.tx.send(Cmd::Stop); + } + + pub fn locate(&self, at: Samples) { + self.shared.position.store(at, Ordering::Relaxed); + let _ = self.tx.send(Cmd::Locate(at)); + } + + pub fn set_speed(&self, speed: f32) { + let v = if speed.is_finite() { (speed.clamp(0.1, 4.0) * 1000.0) as u32 } else { 1000 }; + self.shared.speed.store(v, Ordering::Relaxed); + } + + pub fn position(&self) -> Samples { + self.shared.position.load(Ordering::Relaxed) + } + + pub fn is_playing(&self) -> bool { + self.shared.playing.load(Ordering::Relaxed) + } + + pub fn meters(&self) -> MeterSnapshot { + self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone() + } +} + +fn open_device(want_rate: u32) -> Result<(cpal::Device, cpal::StreamConfig, String), String> { + use cpal::traits::{DeviceTrait, HostTrait}; + let host = cpal::default_host(); + let device = host.default_output_device().ok_or_else(|| "no default output device".to_string())?; + #[allow(deprecated)] + let name = device.name().unwrap_or_else(|_| "Audio device".into()); + // Prefer an f32 config at the session rate; else the device default (we resample). + let mut chosen: Option = None; + if let Ok(configs) = device.supported_output_configs() { + for c in configs { + if c.sample_format() == cpal::SampleFormat::F32 + && c.min_sample_rate().0 <= want_rate + && c.max_sample_rate().0 >= want_rate + && c.channels() >= 2 + { + chosen = Some(c.with_sample_rate(cpal::SampleRate(want_rate)).config()); + break; + } + } + } + let config = match chosen { + Some(c) => c, + None => { + let d = device.default_output_config().map_err(|e| e.to_string())?; + if d.sample_format() != cpal::SampleFormat::F32 { + return Err(format!("unsupported device sample format {:?}", d.sample_format())); + } + d.config() + } + }; + Ok((device, config, name)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn audio_state_plays_and_stops_at_end() { + let s = Arc::new(soundcraft_model::Session::default()); + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0); + tx.send(Cmd::Play { from: 0, end: Some(1000), looped: None }).unwrap(); + let mut out = vec![0.0f32; 4096]; + st.fill(&mut out, 2); + assert!(shared.position.load(Ordering::Relaxed) >= 1000); + assert!(!shared.playing.load(Ordering::Relaxed)); + } + + #[test] + fn looping_wraps() { + let s = Arc::new(soundcraft_model::Session::default()); + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0); + tx.send(Cmd::Play { from: 0, end: None, looped: Some(Range::new(0, 1000)) }).unwrap(); + let mut out = vec![0.0f32; 20_000]; + st.fill(&mut out, 2); + let p = shared.position.load(Ordering::Relaxed); + assert!(p <= 1000, "position {p} should stay inside the loop"); + assert!(shared.playing.load(Ordering::Relaxed)); + } +} diff --git a/crates/time/Cargo.toml b/crates/time/Cargo.toml new file mode 100644 index 00000000..df5dc4e1 --- /dev/null +++ b/crates/time/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "soundcraft-time" +description = "SoundCraft timebases: samples, Bars|Beats, Min:Secs, Timecode, Feet+Frames, tempo and meter maps, grid" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs new file mode 100644 index 00000000..a5f4a1ab --- /dev/null +++ b/crates/time/src/format.rs @@ -0,0 +1,171 @@ +//! Formatting and parsing positions in the five timebases. + +use crate::{BarBeat, FrameRate, SampleRate, Samples, TempoMap, TimeError, Timecode, timecode::feet_frames}; + +/// The counters' and rulers' timebases. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum TimeFormat { + BarsBeats, + #[default] + MinSecs, + Timecode, + FeetFrames, + Samples, +} + +impl TimeFormat { + pub const ALL: [TimeFormat; 5] = [TimeFormat::BarsBeats, TimeFormat::MinSecs, TimeFormat::Timecode, TimeFormat::FeetFrames, TimeFormat::Samples]; + + pub fn label(self) -> &'static str { + match self { + TimeFormat::BarsBeats => "Bars|Beats", + TimeFormat::MinSecs => "Min:Secs", + TimeFormat::Timecode => "Timecode", + TimeFormat::FeetFrames => "Feet+Frames", + TimeFormat::Samples => "Samples", + } + } + + pub fn id(self) -> &'static str { + match self { + TimeFormat::BarsBeats => "bars_beats", + TimeFormat::MinSecs => "min_secs", + TimeFormat::Timecode => "timecode", + TimeFormat::FeetFrames => "feet_frames", + TimeFormat::Samples => "samples", + } + } + + pub fn from_id(id: &str) -> Option { + TimeFormat::ALL.into_iter().find(|f| f.id() == id || f.label().eq_ignore_ascii_case(id)) + } +} + +fn min_secs(s: Samples, sr: SampleRate) -> String { + let neg = s < 0; + let ms_total = (sr.seconds(s.saturating_abs()) * 1000.0).floor() as i64; + let mins = ms_total / 60_000; + let secs = (ms_total / 1000) % 60; + let ms = ms_total % 1000; + format!("{}{mins}:{secs:02}.{ms:03}", if neg { "-" } else { "" }) +} + +/// Format an absolute position. +pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> String { + match fmt { + TimeFormat::Samples => s.to_string(), + TimeFormat::MinSecs => min_secs(s, sr), + TimeFormat::Timecode => Timecode::from_samples(s.saturating_add(tc_start), rate, sr).format(rate), + TimeFormat::FeetFrames => { + let (ft, fr) = feet_frames(s, rate, sr); + format!("{ft}+{fr:02}") + } + TimeFormat::BarsBeats => { + let bb = map.bar_beat_at(s, sr); + format!("{}| {}| {:03}", bb.bar, bb.beat, bb.tick) + } + } +} + +/// Format a length starting at `start` (Bars|Beats lengths count from 0|0|000). +pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> String { + match fmt { + TimeFormat::BarsBeats => { + let t0 = map.samples_to_ticks(start, sr); + let t1 = map.samples_to_ticks(start.saturating_add(len), sr); + let m = map.meter_at_tick(t0); + let d = (t1 - t0).max(0); + let bars = d / m.ticks_per_bar().max(1); + let rem = d % m.ticks_per_bar().max(1); + format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1)) + } + TimeFormat::Timecode => Timecode::from_samples(len, rate, sr).format(rate), + other => format_position(len, other, sr, map, rate, 0), + } +} + +/// Parse a position typed into a counter or dialog. +pub fn parse_position(text: &str, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> Result { + let t = text.trim(); + let err = || TimeError::Parse(t.to_string(), fmt.label()); + match fmt { + TimeFormat::Samples => t.replace([',', '_'], "").parse::().map_err(|_| err()), + TimeFormat::MinSecs => { + let (neg, body) = match t.strip_prefix('-') { + Some(r) => (true, r), + None => (false, t), + }; + let (m, s) = match body.split_once(':') { + Some((m, s)) => (m.trim().parse::().map_err(|_| err())?, s.trim().parse::().map_err(|_| err())?), + None => (0.0, body.parse::().map_err(|_| err())?), + }; + if !m.is_finite() || !s.is_finite() || m < 0.0 || s < 0.0 { + return Err(err()); + } + 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::FeetFrames => { + let (ft, fr) = t.split_once('+').ok_or_else(err)?; + let ft: i64 = ft.trim().parse().map_err(|_| err())?; + let fr: i64 = fr.trim().parse().map_err(|_| err())?; + Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr), sr)) + } + TimeFormat::BarsBeats => { + let parts: Vec = + t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(|p| p.parse::()).collect::>().map_err(|_| err())?; + let bb = BarBeat { + bar: *parts.first().ok_or_else(err)?, + beat: parts.get(1).copied().unwrap_or(1).max(1), + tick: parts.get(2).copied().unwrap_or(0).max(0), + }; + Ok(map.samples_at_bar_beat(bb, sr)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> (SampleRate, TempoMap, FrameRate) { + (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30) + } + + #[test] + fn formats_like_pro_tools_counters() { + let (sr, map, rate) = ctx(); + assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0), "0:00.000"); + assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0), "1:15.500"); + assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0), "1| 1| 000"); + assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0), "00:00:01:00"); + assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0), "12345"); + } + + #[test] + fn parse_round_trips() { + let (sr, map, rate) = ctx(); + for fmt in TimeFormat::ALL { + for s in [0i64, 48_000, 96_000 * 3, 1_440_000] { + let text = format_position(s, fmt, sr, &map, rate, 0); + let back = parse_position(&text, fmt, sr, &map, rate, 0).unwrap(); + assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}"); + } + } + } + + #[test] + fn parse_rejects_garbage() { + let (sr, map, rate) = ctx(); + assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0).is_err()); + assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0).is_err()); + assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0).is_err()); + } + + #[test] + fn bar_lengths() { + let (sr, map, rate) = ctx(); + assert_eq!(format_length(0, 96_000 + 24_000, TimeFormat::BarsBeats, sr, &map, rate), "1| 1| 000"); + } +} diff --git a/crates/time/src/grid.rs b/crates/time/src/grid.rs new file mode 100644 index 00000000..30e8f63f --- /dev/null +++ b/crates/time/src/grid.rs @@ -0,0 +1,202 @@ +//! Grid and Nudge values: musical or absolute step sizes, grid lines and snapping. + +use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, to_samples}; + +/// Note values for Bars|Beats grids. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum NoteValue { + Bar, + Half, + Quarter, + Eighth, + Sixteenth, + ThirtySecond, + SixtyFourth, +} + +impl NoteValue { + pub const ALL: [NoteValue; 7] = [ + NoteValue::Bar, + NoteValue::Half, + NoteValue::Quarter, + NoteValue::Eighth, + NoteValue::Sixteenth, + NoteValue::ThirtySecond, + NoteValue::SixtyFourth, + ]; + pub fn ticks(self) -> i64 { + match self { + NoteValue::Bar => TICKS_PER_QUARTER * 4, + NoteValue::Half => TICKS_PER_QUARTER * 2, + NoteValue::Quarter => TICKS_PER_QUARTER, + NoteValue::Eighth => TICKS_PER_QUARTER / 2, + NoteValue::Sixteenth => TICKS_PER_QUARTER / 4, + NoteValue::ThirtySecond => TICKS_PER_QUARTER / 8, + NoteValue::SixtyFourth => TICKS_PER_QUARTER / 16, + } + } + pub fn label(self) -> &'static str { + match self { + NoteValue::Bar => "1 bar", + NoteValue::Half => "1/2 note", + NoteValue::Quarter => "1/4 note", + NoteValue::Eighth => "1/8 note", + NoteValue::Sixteenth => "1/16 note", + NoteValue::ThirtySecond => "1/32 note", + NoteValue::SixtyFourth => "1/64 note", + } + } +} + +/// A grid or nudge step. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum GridValue { + /// Musical: note value, optionally dotted or triplet. + Note { value: NoteValue, dotted: bool, triplet: bool }, + /// Absolute seconds (Min:Secs grids: 0.001 … 10 s). + Seconds(f64), + /// Frames at the session timecode rate. + Frames(i64), + /// Raw samples. + Samples(i64), +} + +impl Default for GridValue { + fn default() -> Self { + GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false } + } +} + +impl GridValue { + pub fn label(&self) -> String { + match self { + GridValue::Note { value, dotted, triplet } => { + let mut s = value.label().to_string(); + if *dotted { + s.push_str(" dotted"); + } + if *triplet { + s.push_str(" triplet"); + } + s + } + GridValue::Seconds(s) => format!("{s:.3} sec"), + GridValue::Frames(f) => format!("{f} frame{}", if *f == 1 { "" } else { "s" }), + GridValue::Samples(n) => format!("{n} samples"), + } + } + + fn note_ticks(value: NoteValue, dotted: bool, triplet: bool, map: &TempoMap, at_tick: i64) -> i64 { + let base = if value == NoteValue::Bar { map.meter_at_tick(at_tick).ticks_per_bar() } else { value.ticks() }; + let mut t = base; + if dotted { + t = t * 3 / 2; + } + if triplet { + t = t * 2 / 3; + } + t.max(1) + } + + /// The step in samples at position `at` (musical steps depend on tempo). + pub fn step_samples(&self, at: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + match *self { + 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) + } + GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1), + GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1), + GridValue::Samples(n) => n.max(1), + } + } + + /// Nearest grid line to `pos`. + pub fn snap(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + let lo = self.line_at_or_before(pos, sr, map, rate); + let hi = self.next_line(lo, sr, map, rate); + if (pos - lo) <= (hi - pos) { lo } else { hi } + } + + /// The grid line at or before `pos`. + pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + match *self { + GridValue::Note { value, dotted, triplet } => { + let t = map.samples_to_ticks_f(pos, sr).floor() as i64; + // Grid lines restart at each bar line so odd meters stay aligned. + let bb = map.bar_beat_at_tick(t); + let bar_tick = map.bar_start_tick(bb.bar); + let step = Self::note_ticks(value, dotted, triplet, map, bar_tick); + let k = (t - bar_tick).div_euclid(step); + map.tick_to_samples(bar_tick + k * step, sr) + } + _ => { + let step = self.step_samples(pos, sr, map, rate); + pos.div_euclid(step) * step + } + } + } + + /// The first grid line strictly after `line`. + pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + let step = self.step_samples(line, sr, map, rate); + let candidate = self.line_at_or_before(line.saturating_add(step), sr, map, rate); + if candidate > line { candidate } else { line.saturating_add(step) } + } + + /// All grid lines within `[start, end)`, capped at `max` lines. + pub fn lines(&self, start: Samples, end: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate, max: usize) -> Vec { + let mut out = Vec::new(); + let mut p = self.line_at_or_before(start, sr, map, rate); + while p < end && out.len() < max { + if p >= start { + out.push(p); + } + let n = self.next_line(p, sr, map, rate); + if n <= p { + break; + } + p = n; + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sixteenth_grid_at_120() { + let g = GridValue::default(); + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30); + assert_eq!(g.step_samples(0, sr, &map, rate), 6000); + assert_eq!(g.snap(2900, sr, &map, rate), 0); + assert_eq!(g.snap(3100, sr, &map, rate), 6000); + assert_eq!(g.lines(0, 24_000, sr, &map, rate, 100), vec![0, 6000, 12000, 18000]); + } + + #[test] + fn absolute_grids() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25); + assert_eq!(GridValue::Seconds(1.0).snap(70_000, sr, &map, rate), 48_000); + assert_eq!(GridValue::Frames(1).step_samples(0, sr, &map, rate), 1920); + assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1); + } + + #[test] + fn lines_are_capped() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25); + assert_eq!(GridValue::Samples(1).lines(0, 1_000_000, sr, &map, rate, 50).len(), 50); + } + + #[test] + fn triplets_and_dots() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25); + let t = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: true }; + assert_eq!(t.step_samples(0, sr, &map, rate), 16_000); + let d = GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }; + assert_eq!(d.step_samples(0, sr, &map, rate), 36_000); + } +} diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs new file mode 100644 index 00000000..4b689348 --- /dev/null +++ b/crates/time/src/lib.rs @@ -0,0 +1,151 @@ +//! SoundCraft timebases. +//! +//! Every position in a session is an absolute sample index (`i64`) at the session sample rate. +//! This crate converts those positions to and from the five Pro Tools-style timebases +//! (Bars|Beats, Min:Secs, Timecode, Feet+Frames, Samples) through a [`TempoMap`], and computes +//! grid lines and snapping for the Grid / Nudge values. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod format; +mod grid; +mod tempo; +mod timecode; + +pub use format::{TimeFormat, format_length, format_position, parse_position}; +pub use grid::{GridValue, NoteValue}; +pub use tempo::{BarBeat, MeterEvent, TICKS_PER_QUARTER, TempoEvent, TempoMap}; +pub use timecode::{FrameRate, Timecode}; + +/// A position or length in samples. +pub type Samples = i64; + +/// Errors from parsing or converting time values. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum TimeError { + #[error("cannot parse `{0}` as a {1} value")] + Parse(String, &'static str), + #[error("invalid sample rate {0}")] + SampleRate(u32), + #[error("invalid tempo {0}")] + Tempo(f64), + #[error("invalid meter {0}/{1}")] + Meter(u32, u32), +} + +/// Sample rates supported by sessions (Pro Tools offers 44.1 k – 192 k; we accept 8 k – 768 k). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct SampleRate(u32); + +impl SampleRate { + pub const HZ_44100: SampleRate = SampleRate(44_100); + pub const HZ_48000: SampleRate = SampleRate(48_000); + pub const COMMON: [u32; 8] = [44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 352_800, 384_000]; + + pub fn new(hz: u32) -> Result { + if (8_000..=768_000).contains(&hz) { Ok(SampleRate(hz)) } else { Err(TimeError::SampleRate(hz)) } + } + pub fn hz(self) -> u32 { + self.0 + } + pub fn as_f64(self) -> f64 { + f64::from(self.0) + } + /// Samples → seconds. + pub fn seconds(self, s: Samples) -> f64 { + s as f64 / self.as_f64() + } + /// Seconds → samples, rounded to nearest and saturated. + pub fn samples(self, seconds: f64) -> Samples { + to_samples(seconds * self.as_f64()) + } +} + +impl Default for SampleRate { + fn default() -> Self { + SampleRate::HZ_48000 + } +} + +/// Round a float sample count to `i64`, mapping NaN to 0 and saturating infinities. +pub fn to_samples(v: f64) -> Samples { + if v.is_nan() { + 0 + } else if v >= i64::MAX as f64 { + i64::MAX + } else if v <= i64::MIN as f64 { + i64::MIN + } else { + v.round() as i64 + } +} + +/// A half-open sample range `[start, end)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub struct Range { + pub start: Samples, + pub end: Samples, +} + +impl Range { + pub fn new(a: Samples, b: Samples) -> Self { + if a <= b { Range { start: a, end: b } } else { Range { start: b, end: a } } + } + pub fn point(at: Samples) -> Self { + Range { start: at, end: at } + } + pub fn len(&self) -> Samples { + self.end.saturating_sub(self.start) + } + pub fn is_empty(&self) -> bool { + self.end <= self.start + } + pub fn contains(&self, s: Samples) -> bool { + s >= self.start && s < self.end + } + pub fn overlaps(&self, o: &Range) -> bool { + self.start < o.end && o.start < self.end + } + pub fn intersect(&self, o: &Range) -> Option { + let s = self.start.max(o.start); + let e = self.end.min(o.end); + (s < e).then_some(Range { start: s, end: e }) + } + pub fn shifted(&self, by: Samples) -> Range { + Range { start: self.start.saturating_add(by), end: self.end.saturating_add(by) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sample_rate_bounds() { + assert!(SampleRate::new(48_000).is_ok()); + assert!(SampleRate::new(0).is_err()); + assert!(SampleRate::new(10_000_000).is_err()); + } + + #[test] + fn seconds_round_trip() { + let sr = SampleRate::HZ_44100; + assert_eq!(sr.samples(1.0), 44_100); + assert!((sr.seconds(22_050) - 0.5).abs() < 1e-12); + } + + #[test] + fn to_samples_is_total() { + assert_eq!(to_samples(f64::NAN), 0); + assert_eq!(to_samples(f64::INFINITY), i64::MAX); + assert_eq!(to_samples(f64::NEG_INFINITY), i64::MIN); + } + + #[test] + fn range_ops() { + let a = Range::new(10, 0); + assert_eq!(a, Range { start: 0, end: 10 }); + assert_eq!(a.intersect(&Range::new(5, 20)), Some(Range::new(5, 10))); + assert_eq!(a.intersect(&Range::new(10, 20)), None); + assert!(a.contains(9) && !a.contains(10)); + } +} diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs new file mode 100644 index 00000000..5b87a3a7 --- /dev/null +++ b/crates/time/src/tempo.rs @@ -0,0 +1,336 @@ +//! Tempo and meter maps: the conversion between musical time (ticks) and samples. + +use crate::{SampleRate, Samples, TimeError, to_samples}; + +/// Ticks per quarter note (Pro Tools' resolution). +pub const TICKS_PER_QUARTER: i64 = 960; + +/// A tempo change at a tick position. `bpm` is quarter notes per minute. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct TempoEvent { + pub tick: i64, + pub bpm: f64, +} + +/// A meter (time signature) change at a tick position, which must fall on a bar line. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MeterEvent { + pub tick: i64, + pub numerator: u32, + pub denominator: u32, +} + +impl MeterEvent { + pub fn ticks_per_beat(&self) -> i64 { + let d = i64::from(self.denominator.max(1)); + (TICKS_PER_QUARTER * 4 / d).max(1) + } + pub fn ticks_per_bar(&self) -> i64 { + self.ticks_per_beat().saturating_mul(i64::from(self.numerator.max(1))) + } +} + +/// A musical position: 1-based bar and beat plus ticks within the beat. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct BarBeat { + pub bar: i64, + pub beat: i64, + pub tick: i64, +} + +/// Tempo and meter changes for a session. Always has at least one of each, at tick 0. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct TempoMap { + tempos: Vec, + meters: Vec, +} + +impl Default for TempoMap { + fn default() -> Self { + TempoMap { tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }], meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }] } + } +} + +fn valid_bpm(bpm: f64) -> Result { + if bpm.is_finite() && (5.0..=1000.0).contains(&bpm) { Ok(bpm) } else { Err(TimeError::Tempo(bpm)) } +} + +impl TempoMap { + pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result { + let mut m = TempoMap::default(); + m.set_tempo(0, bpm)?; + m.set_meter(0, numerator, denominator)?; + Ok(m) + } + + pub fn tempos(&self) -> &[TempoEvent] { + &self.tempos + } + pub fn meters(&self) -> &[MeterEvent] { + &self.meters + } + + /// Insert or replace the tempo at `tick`. + pub fn set_tempo(&mut self, tick: i64, bpm: f64) -> Result<(), TimeError> { + let bpm = valid_bpm(bpm)?; + let tick = tick.max(0); + match self.tempos.binary_search_by(|e| e.tick.cmp(&tick)) { + Ok(i) => { + if let Some(e) = self.tempos.get_mut(i) { + e.bpm = bpm; + } + } + Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }), + } + Ok(()) + } + + /// Remove a tempo event (the one at tick 0 is kept). + pub fn remove_tempo(&mut self, tick: i64) -> bool { + if tick == 0 { + return false; + } + let before = self.tempos.len(); + self.tempos.retain(|e| e.tick != tick); + before != self.tempos.len() + } + + /// Insert or replace the meter at `tick` (snapped to the bar line at or before it). + pub fn set_meter(&mut self, tick: i64, numerator: u32, denominator: u32) -> Result<(), TimeError> { + if !(1..=64).contains(&numerator) || ![1, 2, 4, 8, 16, 32, 64].contains(&denominator) { + return Err(TimeError::Meter(numerator, denominator)); + } + let tick = if tick <= 0 { 0 } else { self.bar_start_tick(self.bar_beat_at_tick(tick).bar) }; + let ev = MeterEvent { tick, numerator, denominator }; + match self.meters.binary_search_by(|e| e.tick.cmp(&tick)) { + Ok(i) => { + if let Some(e) = self.meters.get_mut(i) { + *e = ev; + } + } + Err(i) => self.meters.insert(i, ev), + } + Ok(()) + } + + pub fn remove_meter(&mut self, tick: i64) -> bool { + if tick == 0 { + return false; + } + let before = self.meters.len(); + self.meters.retain(|e| e.tick != tick); + before != self.meters.len() + } + + /// Tempo in effect at `tick`. + pub fn tempo_at_tick(&self, tick: i64) -> f64 { + let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + for e in &self.tempos { + if e.tick <= tick { + bpm = e.bpm; + } else { + break; + } + } + bpm + } + + /// Meter in effect at `tick`. + pub fn meter_at_tick(&self, tick: i64) -> MeterEvent { + let mut m = self.meters.first().copied().unwrap_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }); + for e in &self.meters { + if e.tick <= tick { + m = *e; + } else { + break; + } + } + m + } + + fn samples_per_tick(bpm: f64, sr: SampleRate) -> f64 { + sr.as_f64() * 60.0 / (bpm * TICKS_PER_QUARTER as f64) + } + + /// Ticks → samples (exact for constant-tempo segments). + pub fn tick_to_samples(&self, tick: i64, sr: SampleRate) -> Samples { + to_samples(self.tick_to_samples_f(tick, sr)) + } + + fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 { + let mut acc = 0.0; + let mut seg_tick = 0i64; + let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + for e in self.tempos.iter().skip(1) { + if e.tick >= tick { + break; + } + acc += (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr); + seg_tick = e.tick; + bpm = e.bpm; + } + acc + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr) + } + + /// Samples → ticks (fractional). + pub fn samples_to_ticks_f(&self, s: Samples, sr: SampleRate) -> f64 { + let s = s as f64; + let mut acc = 0.0; + let mut seg_tick = 0i64; + let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + for e in self.tempos.iter().skip(1) { + let seg = (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr); + if acc + seg > s { + break; + } + acc += seg; + seg_tick = e.tick; + bpm = e.bpm; + } + seg_tick as f64 + (s - acc) / Self::samples_per_tick(bpm, sr) + } + + pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 { + to_samples(self.samples_to_ticks_f(s, sr)) + } + + /// Tick of the first beat of `bar` (1-based; bars before 1 extend the first meter backwards). + pub fn bar_start_tick(&self, bar: i64) -> i64 { + let mut cur_bar = 1i64; + let mut cur_tick = 0i64; + let first = self.meter_at_tick(0); + if bar < 1 { + return (bar - 1).saturating_mul(first.ticks_per_bar()); + } + 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); + match bars_in_seg { + Some(n) if bar >= cur_bar + n => { + cur_bar += n; + cur_tick = next_tick.unwrap_or(cur_tick); + } + _ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)), + } + } + cur_tick + } + + /// Bar|beat|tick at `tick`. + pub fn bar_beat_at_tick(&self, tick: i64) -> BarBeat { + let first = self.meter_at_tick(0); + if tick < 0 { + let tpb = first.ticks_per_bar(); + let bars_back = (-tick + tpb - 1) / tpb; + let bar_tick = -bars_back * tpb; + let within = tick - bar_tick; + let beat_t = first.ticks_per_beat(); + return BarBeat { bar: 1 - bars_back, beat: within / beat_t + 1, tick: within % beat_t }; + } + let mut bar = 1i64; + let mut seg_tick = 0i64; + for (i, m) in self.meters.iter().enumerate() { + let tpb = m.ticks_per_bar(); + if let Some(n) = self.meters.get(i + 1) + && tick >= n.tick + { + bar += ((n.tick - seg_tick) + tpb - 1) / tpb; + seg_tick = n.tick; + continue; + } + let rel = tick - seg_tick; + let bars = rel / tpb; + let within = rel % tpb; + let beat_t = m.ticks_per_beat(); + return BarBeat { bar: bar + bars, beat: within / beat_t + 1, tick: within % beat_t }; + } + BarBeat { bar, beat: 1, tick: 0 } + } + + /// Bar|beat|tick → tick. + 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) + } + + pub fn bar_beat_at(&self, s: Samples, sr: SampleRate) -> BarBeat { + self.bar_beat_at_tick(self.samples_to_ticks(s, sr)) + } + + pub fn samples_at_bar_beat(&self, bb: BarBeat, sr: SampleRate) -> Samples { + self.tick_to_samples(self.tick_at_bar_beat(bb), sr) + } + + /// Scale every tempo by `factor` (Tempo Operations › Scale). + pub fn scale_tempos(&mut self, factor: f64) -> Result<(), TimeError> { + let mut out = self.tempos.clone(); + for e in &mut out { + e.bpm = valid_bpm(e.bpm * factor)?; + } + self.tempos = out; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_map_is_120_4_4() { + let m = TempoMap::default(); + let sr = SampleRate::HZ_48000; + // One bar of 4/4 at 120 bpm = 2 seconds. + assert_eq!(m.samples_at_bar_beat(BarBeat { bar: 2, beat: 1, tick: 0 }, sr), 96_000); + assert_eq!(m.bar_beat_at(96_000, sr), BarBeat { bar: 2, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 }); + } + + #[test] + fn tempo_change_is_integrated() { + let mut m = TempoMap::default(); + let sr = SampleRate::HZ_48000; + m.set_tempo(TICKS_PER_QUARTER * 4, 60.0).unwrap(); + // Bar 1 at 120 = 96000 samples; bar 2 at 60 = 192000 samples. + assert_eq!(m.tick_to_samples(TICKS_PER_QUARTER * 8, sr), 96_000 + 192_000); + assert_eq!(m.samples_to_ticks(96_000 + 192_000, sr), TICKS_PER_QUARTER * 8); + } + + #[test] + fn meter_change_counts_bars() { + let mut m = TempoMap::default(); + // 2 bars of 4/4 then 3/4. + m.set_meter(TICKS_PER_QUARTER * 8, 3, 4).unwrap(); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 8), BarBeat { bar: 3, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 11), BarBeat { bar: 4, beat: 1, tick: 0 }); + assert_eq!(m.bar_start_tick(4), TICKS_PER_QUARTER * 11); + let bb = BarBeat { bar: 4, beat: 2, tick: 10 }; + assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb); + } + + #[test] + fn eighth_note_meters() { + let m = TempoMap::new(120.0, 6, 8).unwrap(); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 3), BarBeat { bar: 2, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER / 2), BarBeat { bar: 1, beat: 2, tick: 0 }); + } + + #[test] + fn rejects_bad_values() { + let mut m = TempoMap::default(); + assert!(m.set_tempo(0, f64::NAN).is_err()); + assert!(m.set_tempo(0, 0.0).is_err()); + assert!(m.set_meter(0, 0, 4).is_err()); + assert!(m.set_meter(0, 4, 3).is_err()); + assert!(!m.remove_tempo(0)); + } + + #[test] + fn negative_positions() { + let m = TempoMap::default(); + assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER * 4), BarBeat { bar: 0, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER), BarBeat { bar: 0, beat: 4, tick: 0 }); + } +} diff --git a/crates/time/src/timecode.rs b/crates/time/src/timecode.rs new file mode 100644 index 00000000..c0db20f3 --- /dev/null +++ b/crates/time/src/timecode.rs @@ -0,0 +1,251 @@ +//! SMPTE timecode and Feet+Frames. + +use crate::{SampleRate, Samples, TimeError}; + +/// Session timecode rates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum FrameRate { + Fps23_976, + #[default] + Fps24, + Fps25, + Fps29_97, + Fps29_97Drop, + Fps30, + Fps47_952, + Fps48, + Fps50, + Fps59_94, + Fps59_94Drop, + Fps60, + Fps100, + Fps119_88, + Fps120, +} + +impl FrameRate { + pub const ALL: [FrameRate; 15] = [ + FrameRate::Fps23_976, + FrameRate::Fps24, + FrameRate::Fps25, + FrameRate::Fps29_97, + FrameRate::Fps29_97Drop, + FrameRate::Fps30, + FrameRate::Fps47_952, + FrameRate::Fps48, + FrameRate::Fps50, + FrameRate::Fps59_94, + FrameRate::Fps59_94Drop, + FrameRate::Fps60, + FrameRate::Fps100, + FrameRate::Fps119_88, + FrameRate::Fps120, + ]; + + /// Real frames per second as a rational (num, den). + pub fn rational(self) -> (i64, i64) { + match self { + FrameRate::Fps23_976 => (24_000, 1001), + FrameRate::Fps24 => (24, 1), + FrameRate::Fps25 => (25, 1), + FrameRate::Fps29_97 | FrameRate::Fps29_97Drop => (30_000, 1001), + FrameRate::Fps30 => (30, 1), + FrameRate::Fps47_952 => (48_000, 1001), + FrameRate::Fps48 => (48, 1), + FrameRate::Fps50 => (50, 1), + FrameRate::Fps59_94 | FrameRate::Fps59_94Drop => (60_000, 1001), + FrameRate::Fps60 => (60, 1), + FrameRate::Fps100 => (100, 1), + FrameRate::Fps119_88 => (120_000, 1001), + FrameRate::Fps120 => (120, 1), + } + } + + /// Frames per labelled second (the counting base). + pub fn nominal(self) -> i64 { + let (n, d) = self.rational(); + (n + d / 2) / d + } + + pub fn is_drop(self) -> bool { + matches!(self, FrameRate::Fps29_97Drop | FrameRate::Fps59_94Drop) + } + + pub fn label(self) -> &'static str { + match self { + FrameRate::Fps23_976 => "23.976", + FrameRate::Fps24 => "24", + FrameRate::Fps25 => "25", + FrameRate::Fps29_97 => "29.97", + FrameRate::Fps29_97Drop => "29.97 Drop", + FrameRate::Fps30 => "30", + FrameRate::Fps47_952 => "47.952", + FrameRate::Fps48 => "48", + FrameRate::Fps50 => "50", + FrameRate::Fps59_94 => "59.94", + FrameRate::Fps59_94Drop => "59.94 Drop", + FrameRate::Fps60 => "60", + FrameRate::Fps100 => "100", + FrameRate::Fps119_88 => "119.88", + FrameRate::Fps120 => "120", + } + } + + /// Whole frame index at a sample position (floored). + pub fn frames_at(self, s: Samples, sr: SampleRate) -> i64 { + let (n, d) = self.rational(); + let num = i128::from(s) * i128::from(n); + let den = i128::from(sr.hz()) * i128::from(d); + if den == 0 { + return 0; + } + i64::try_from(num.div_euclid(den)).unwrap_or(0) + } + + /// Sample position of the start of frame `f`. + pub fn samples_at_frame(self, f: i64, sr: SampleRate) -> Samples { + let (n, d) = self.rational(); + if n == 0 { + return 0; + } + let v = (i128::from(f) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n); + i64::try_from(v).unwrap_or(0) + } + + /// Samples per frame (fractional). + pub fn samples_per_frame(self, sr: SampleRate) -> f64 { + let (n, d) = self.rational(); + sr.as_f64() * d as f64 / n as f64 + } +} + +/// A timecode label. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Timecode { + pub negative: bool, + pub hours: i64, + pub minutes: i64, + pub seconds: i64, + pub frames: i64, +} + +impl Timecode { + /// Label for an absolute frame count. + pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode { + let negative = frames < 0; + let mut f = frames.saturating_abs(); + let base = rate.nominal().max(1); + if rate.is_drop() { + // Drop 2 (or 4) labels each minute except every tenth. + let drop = if base >= 60 { 4 } else { 2 }; + let per_10min = base * 600 - drop * 9; + let per_min = base * 60 - drop; + let tens = f / per_10min; + let rem = f % per_10min; + let extra = if rem > drop { drop * 9 * tens + drop * ((rem - drop) / per_min) } else { drop * 9 * tens }; + f += extra; + } + let frames_l = f % base; + let total_secs = f / base; + Timecode { negative, hours: total_secs / 3600, minutes: (total_secs / 60) % 60, seconds: total_secs % 60, frames: frames_l } + } + + /// Absolute frame count of this label. + pub fn to_frames(&self, rate: FrameRate) -> i64 { + let base = rate.nominal().max(1); + let total_min = self.hours * 60 + self.minutes; + let mut f = (total_min * 60 + self.seconds) * base + self.frames; + if rate.is_drop() { + let drop = if base >= 60 { 4 } else { 2 }; + f -= drop * (total_min - total_min / 10); + } + if self.negative { -f } else { f } + } + + pub fn from_samples(s: Samples, rate: FrameRate, sr: SampleRate) -> Timecode { + Timecode::from_frames(rate.frames_at(s, sr), rate) + } + + pub fn to_samples(&self, rate: FrameRate, sr: SampleRate) -> Samples { + rate.samples_at_frame(self.to_frames(rate), sr) + } + + pub fn format(&self, rate: FrameRate) -> String { + let sep = if rate.is_drop() { ';' } else { ':' }; + let sign = if self.negative { "-" } else { "" }; + format!("{sign}{:02}:{:02}:{:02}{sep}{:02}", self.hours, self.minutes, self.seconds, self.frames) + } + + /// Parse `HH:MM:SS:FF` (also `;` / `.` separators and fewer fields, right-aligned). + pub fn parse(text: &str) -> Result { + let t = text.trim(); + let (negative, t) = match t.strip_prefix('-') { + Some(r) => (true, r), + None => (false, t), + }; + let parts: Vec<&str> = t.split([':', ';', '.']).collect(); + if parts.is_empty() || parts.len() > 4 { + return Err(TimeError::Parse(text.to_string(), "timecode")); + } + let mut nums = [0i64; 4]; + let offset = 4 - parts.len(); + for (i, p) in parts.iter().enumerate() { + let v: i64 = p.trim().parse().map_err(|_| TimeError::Parse(text.to_string(), "timecode"))?; + if v < 0 { + return Err(TimeError::Parse(text.to_string(), "timecode")); + } + if let Some(slot) = nums.get_mut(offset + i) { + *slot = v; + } + } + Ok(Timecode { negative, hours: nums[0], minutes: nums[1], seconds: nums[2], frames: nums[3] }) + } +} + +/// Feet+Frames for 35 mm film (16 frames per foot). +pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate) -> (i64, i64) { + let f = rate.frames_at(s, sr); + (f.div_euclid(16), f.rem_euclid(16)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_drop_round_trip() { + let sr = SampleRate::HZ_48000; + let tc = Timecode::from_samples(48_000 * 3661, FrameRate::Fps25, sr); + assert_eq!(tc.format(FrameRate::Fps25), "01:01:01:00"); + assert_eq!(tc.to_samples(FrameRate::Fps25, sr), 48_000 * 3661); + } + + #[test] + fn drop_frame_labels() { + let r = FrameRate::Fps29_97Drop; + // Frame 1800 is the first frame of minute 1 → labelled 00:01:00;02. + assert_eq!(Timecode::from_frames(1800, r).format(r), "00:01:00;02"); + assert_eq!(Timecode::from_frames(1799, r).format(r), "00:00:59;29"); + // Minute 10 keeps its labels. + assert_eq!(Timecode::from_frames(17_982, r).format(r), "00:10:00;00"); + for f in [0, 1, 1799, 1800, 1801, 17_981, 17_982, 107_892, 500_000] { + assert_eq!(Timecode::from_frames(f, r).to_frames(r), f, "frame {f}"); + } + } + + #[test] + fn parse_variants() { + let tc = Timecode::parse("01:02:03:04").unwrap(); + assert_eq!((tc.hours, tc.minutes, tc.seconds, tc.frames), (1, 2, 3, 4)); + let tc = Timecode::parse("3:04").unwrap(); + assert_eq!((tc.seconds, tc.frames), (3, 4)); + assert!(Timecode::parse("a:b").is_err()); + assert!(Timecode::parse("1:2:3:4:5").is_err()); + } + + #[test] + fn feet_and_frames() { + let sr = SampleRate::HZ_48000; + assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr), (1, 8)); + } +} diff --git a/docs/brand/LICENSE-brand.txt b/docs/brand/LICENSE-brand.txt new file mode 100644 index 00000000..4071af35 --- /dev/null +++ b/docs/brand/LICENSE-brand.txt @@ -0,0 +1,42 @@ +ArtCraft brand assets: license + +The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this +directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks +and are NOT open source. They are not covered by SoundCraft's MIT OR Apache-2.0 license, and nothing in +that license, in NOTICE or in any other file of this repository grants any right to them. + +Permission + +The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable, royalty-free +permission to use the ArtCraft Marks only: + + 1. as part of copies of this repository; and + 2. within SoundCraft itself, its documentation and its official builds, to identify SoundCraft as made + by the ArtCraft team and community and to link to getartcraft.com and its community. + +Conditions + +The permission holds only while every one of these is true: + + - The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop, + stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine + them with any other name, mark or artwork. + - They are used only in the context of SoundCraft, and only to say that the ArtCraft team makes it. + They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports + any other product, service, fork, modified version, plug-in, organization or person. + - Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media + handle, app-store listing, advertising or marketing of anything other than SoundCraft. + +Forks and modified versions + +If you distribute or publish a modified version of SoundCraft, or any work derived from it, you must +remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or +present it as an ArtCraft product. You may say, in plain text, that your work is based on SoundCraft by +the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks +unchanged. + +Everything else + +Any use not expressly permitted here needs prior written permission from the ArtCraft Team +(https://getartcraft.com/). The permission ends automatically for anyone who breaks these terms. +All other rights are reserved. diff --git a/docs/brand/artcraft-logo-white.png b/docs/brand/artcraft-logo-white.png new file mode 100644 index 00000000..8cfb31d9 Binary files /dev/null and b/docs/brand/artcraft-logo-white.png differ diff --git a/docs/brand/artcraft-logo-white.svg b/docs/brand/artcraft-logo-white.svg new file mode 100644 index 00000000..4af80f75 --- /dev/null +++ b/docs/brand/artcraft-logo-white.svg @@ -0,0 +1,7 @@ + + ArtCraft + + + + + diff --git a/docs/brand/artcraft-logo.png b/docs/brand/artcraft-logo.png new file mode 100644 index 00000000..a4d1544c Binary files /dev/null and b/docs/brand/artcraft-logo.png differ diff --git a/docs/brand/artcraft-logo.svg b/docs/brand/artcraft-logo.svg new file mode 100644 index 00000000..d63f3eae --- /dev/null +++ b/docs/brand/artcraft-logo.svg @@ -0,0 +1,7 @@ + + ArtCraft + + + + + diff --git a/docs/brand/artcraft-mark-black.png b/docs/brand/artcraft-mark-black.png new file mode 100644 index 00000000..27664bc1 Binary files /dev/null and b/docs/brand/artcraft-mark-black.png differ diff --git a/docs/brand/artcraft-mark-black.svg b/docs/brand/artcraft-mark-black.svg new file mode 100644 index 00000000..63943537 --- /dev/null +++ b/docs/brand/artcraft-mark-black.svg @@ -0,0 +1,4 @@ + + ArtCraft + + diff --git a/docs/brand/artcraft-mark.png b/docs/brand/artcraft-mark.png new file mode 100644 index 00000000..dbb3dc08 Binary files /dev/null and b/docs/brand/artcraft-mark.png differ diff --git a/docs/brand/artcraft-mark.svg b/docs/brand/artcraft-mark.svg new file mode 100644 index 00000000..72e191f8 --- /dev/null +++ b/docs/brand/artcraft-mark.svg @@ -0,0 +1,4 @@ + + ArtCraft + + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..3121924d --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 150 +use_small_heuristics = "Max" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 00000000..37b3ea59 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +serde_json.workspace = true diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 00000000..f328e4d9 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1 @@ +fn main() {}